{
  "openapi": "3.0.3",
  "info": {
    "title": "API de ProofSnap",
    "version": "2.0.0",
    "description": "La API de ProofSnap carga una página web en un navegador Chromium del lado del servidor y devuelve un paquete de pruebas (ZIP).\n\nCada paquete de captura contiene la captura de pantalla, el HTML de la página, el texto extraído de la página, metadata.json, forensic_log.json, chain_of_custody.json, evidence.pdf y un manifest.json que lista el SHA-256 de cada archivo. manifest.json se firma con una clave RSA-4096 generada para esa captura (la clave pública se entrega como publickey.pem, la clave privada se descarta) y el SHA-256 de manifest.json se envía a los calendarios de OpenTimestamps para un anclaje en Bitcoin (manifest.json.ots). Si la solicitud a los calendarios falla, el paquete se entrega sin la prueba y manifest.json no la lista, por lo que los scripts offline y el verificador la indican como no presente, no como faltante. Con `include_eidas` en una clave de producción, el mismo hash recibe además un sello cualificado de tiempo electrónico eIDAS (RFC 3161, manifest.json.tsr). Las capturas de pantalla JPEG se firman con C2PA Content Credentials en la medida de lo posible: cuando la firma falla, la captura se completa sin ellas. La carpeta verification/ contiene scripts que comprueban el paquete offline. Cada manifest.json declara `ots_listed_only_when_present: true` (manifest.json.ots solo se lista cuando está en el paquete) y `eidas_marker_version: 1`. Registra `eidas_requested: true` solo cuando el paquete se entrega con su sello cualificado de tiempo. Si no se puede obtener el token, el manifiesto se vuelve a sellar sin él. Por tanto, un paquete que registra `eidas_requested: true` pero no tiene manifest.json.tsr ha perdido su token, y los scripts offline indican FAIL. chain_of_custody.json registra `bitcoin_ots_included` y lista, en `page_modifications.items[]`, cada cambio realizado en la página antes de la captura. manifest.json incluye sealed_at (cuándo se generó, después de calcular el hash de cada archivo) y los desfases de reloj del servidor de captura medidos por SNTP; chain_of_custody.json lista los pasos de la captura en el orden en que ocurrieron, cada uno con la hora en que terminó, y se cierra con custody_record_written (el cálculo de hashes, la firma y los sellos de tiempo vienen después y los acreditan manifest.json y las pruebas).\n\nmetadata.json registra:\n\n- `browser_identity`: el user agent y los Client Hints realmente enviados.\n- `dns`: una comprobación DNS independiente de la dirección a la que se conectó el navegador, con las respuestas de dns.google y cloudflare-dns.com y el resultado: confirmed, not_confirmed, resolvers_unavailable, connected_ip_unknown, skipped_ip_literal (la URL indica una dirección IP, así que no hay nombre de host que consultar) o skipped_internal_host (un nombre de host interno, que nunca se envía a los resolvers públicos).\n- `url_at_load` y `url_at_screenshot`: la dirección justo después de cargarse la página y en el momento en que se tomó su imagen (una página puede cambiar su propia dirección, casi siempre la parte que sigue a #).\n- `redirect_chain`: las redirecciones del documento principal, de la más antigua a la más reciente, cada una con su estado HTTP. Las direcciones solicitada y final se dan completas; en cada salto intermedio los valores de la query se sustituyen por [redacted] (`query_values_redacted`).\n- `tls.source`: de dónde procede cada campo TLS, `browser_connection` o `browser_connection_and_separate_handshake`, este último con `at`, `ip` y `port` del handshake independiente que obtuvo certificate_chain.pem después de cargarse la página.\n\nCuando un paso solicitado no se realizó (un sello de tiempo eIDAS, la firma C2PA, la solicitud al archivo, WHOIS, la segunda lectura del reloj, un WARC truncado por el límite de tiempo, o una espera o una expansión de contenido omitida por el límite de tiempo), evidence.pdf lo indica con un motivo breve y el certificado de procedencia lo menciona en una línea.\n\nLas capturas son asíncronas. POST /captures devuelve 202 con un capture_id; consulta GET /captures/{capture_id} o pasa `options.webhook_url`. Una captura completada devuelve un package_url prefirmado válido durante un máximo de 24 horas (consulta package_url_expires_at); vuelve a leer la captura para obtener un enlace nuevo.\n\nLas rutas siguientes son relativas a la URL del servidor, que ya contiene el stage /v1. Los valores `poll_path` que devuelve la API incluyen /v1.",
    "contact": {
      "email": "support@getproofsnap.com"
    }
  },
  "servers": [
    {
      "url": "https://api.getproofsnap.com/v1",
      "description": "API de ProofSnap (UE)"
    }
  ],
  "security": [
    {
      "ApiKeyAuth": []
    }
  ],
  "tags": [
    {
      "name": "Capturas",
      "description": "Crea, enumera, lee, elimina y verifica capturas.\n\n**Límites de frecuencia.** `POST /captures`, `POST /captures/batch`, `POST /compare`, `POST /captures/{capture_id}/redact` y `POST /certify-hash` tienen un límite de frecuencia por cuenta en ventanas fijas de 60 segundos: todas las claves de API de la cuenta y su sesión del panel comparten una misma ventana (añadir claves no aumenta el límite). Un lote o una certificación de varios hashes consume una unidad por URL o por hash. Límites predeterminados por ventana según el plan:\n\n| Plan | Solicitudes por 60 s |\n| --- | --- |\n| sandbox | 10 |\n| developer | 60 |\n| startup | 120 |\n| growth | 300 |\n| custom | 1200 |\n| legal_solo | 60 |\n| legal_seats | 120 |\n| website_archive | 60 |\n| archive_only | 30 |\n| cualquier otro plan | 30 |\n\nLas siguientes solicitudes también consumen de la misma ventana, una unidad por solicitud salvo que se indique lo contrario: `POST /captures/{capture_id}/verify`, `PATCH /captures/{capture_id}` con la acción legal_hold, `GET /captures/hosts` (solo cuando la lista se vuelve a calcular, no cuando se devuelve un resultado en caché), `POST /legal-holds` (una unidad por cada 10 capturas), `POST /captures/export-jobs`, y `POST /imports`, `POST /imports/{import_id}/complete` y `GET /imports/{import_id}?upload=true`.\n\nCuando se supera el límite, la API devuelve 429 RATE_LIMITED con los encabezados `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` y `X-RateLimit-Reset` y `error.details.retry_after_seconds`. Una solicitud que necesita más unidades de las que permite una ventana (una certificación de varios hashes mayor que el límite por ventana) nunca puede prosperar y devuelve 400 RATE_LIMIT_COST_TOO_HIGH con `error.details` `{ max_per_window, window_seconds, requested }`. Divídela en solicitudes más pequeñas. Una solicitud rechazada no consume unidades, y los encabezados indican el margen restante real.\n\n**Límites mensuales.** Las capturas, comparaciones, ocultaciones y certificaciones con claves de producción cuentan para un máximo mensual por cuenta (mes natural UTC). La respuesta de aceptación incluye encabezados `X-Quota-*`. Alcanzar el máximo devuelve 402 QUOTA_EXCEEDED con `error.details` `{ reason, used, limit, max_limit, cap, org_member }`, donde `cap` es `plan_max`, `customer`, `member_owner` u `org`. Mientras una suscripción de producción esté vencida o impagada, las solicitudes facturables devuelven 402 PAYMENT_REQUIRED; para una cuenta así, las capturas (individuales y por lotes) se rechazan con este error sea cual sea el entorno de la clave, incluidas las claves de prueba.\n\n**Capturas en curso y modo de prueba.** Cada cuenta solo puede ejecutar a la vez un número limitado de capturas (developer 10, startup 25, growth 100, legal_solo y legal_seats 25, custom 200, website_archive 25, sandbox 5). Por encima de ese límite, la API devuelve 429 CONCURRENT_CAPTURE_LIMIT; un lote inicia las URL que caben y enumera las demás en `not_admitted` (cada una con `code` CONCURRENT_CAPTURE_LIMIT, más `in_flight_limit`), así que envíalas de nuevo más tarde. El trabajo en modo de prueba (una clave de prueba, o una sesión sin facturación vinculada) está limitado a 200 unidades por cuenta y día UTC (429 TEST_MODE_DAILY_LIMIT; encabezados `X-Test-Daily-*`), y una captura en modo de prueba nunca se envía a Internet Archive (`external_archive.skip_reason` \"test_mode\")."
    },
    {
      "name": "Comparar y ocultar datos",
      "description": "Genera un nuevo paquete sellado a partir de capturas existentes: una comparación de dos capturas o una copia con datos ocultos de una de ellas."
    },
    {
      "name": "Certificación de hash",
      "description": "Certifica los hashes SHA-256 de archivos que conservas tú. Solo se envían los hashes; los archivos nunca llegan a ProofSnap."
    },
    {
      "name": "Capturas automáticas",
      "description": "Capturas recurrentes (interval o cron) y supervisión de cambios (on_change). Cada ejecución que realiza una captura es una captura normal: se mide y cuenta para los límites mensuales."
    },
    {
      "name": "Asuntos",
      "description": "Espacios de trabajo para casos legales con un registro de custodia encadenado por hash. Disponible en los planes Legal (legal_solo, legal_seats); los demás planes reciben 403 MATTERS_NOT_ENABLED, con dos excepciones. El propietario de una empresa cuya suscripción ha terminado aún puede listar, abrir y exportar los asuntos de esa empresa hasta la fecha de eliminación de datos de la empresa (data_delete_after); este acceso es de solo lectura más exportación, y GET /matters añade entonces read_only: true. Una cuenta con el plan Archive only puede listar, abrir y exportar sus asuntos. Solo el propietario del asunto puede renombrarlo, archivarlo, gestionar sus miembros, sellarlo, compartirlo o exportarlo. El propietario del asunto es quien lo creó. En un asunto de empresa, el propietario de la empresa pasa a ser propietario del asunto solo después de que su creador haya dejado la empresa. Un propietario de empresa añadido como miembro solo tiene derechos de miembro. Los miembros reciben 403 FORBIDDEN para esas operaciones. Toda escritura en un asunto (crear, PATCH, asignar capturas, compartir y revocar el uso compartido, sellar, exportar) requiere una dirección de correo electrónico confirmada (403 EMAIL_NOT_VERIFIED, para una sesión del panel y para una clave de API de una cuenta cuya dirección no está confirmada) y, para quien llama en producción (clave de API de producción o sesión con facturación vinculada), se rechaza con 402 PAYMENT_REQUIRED mientras la suscripción (o la de la empresa) tenga pagos vencidos o impagados. Un asunto archivado rechaza cambios de nombre, nuevos miembros, nuevas capturas, el uso compartido y la exportación (409 MATTER_ARCHIVED) hasta que se reabra; quien no puede reabrirlo (el propietario de una empresa finalizada, una cuenta Archive only) aún puede exportarlo."
    },
    {
      "name": "Webhooks",
      "description": "ProofSnap envía con POST un evento JSON a la `webhook_url` que indicas con una captura, una certificación, una ocultación o una programación. La URL debe usar https, no debe contener credenciales (`user:password@`, 400 INVALID_URL) y no debe apuntar a una dirección privada, de loopback, link-local, de NAT de operador (CGNAT), multicast, reservada o de metadatos de la nube, incluidas las formas IPv6 que incorporan una dirección IPv4 de ese tipo. Esto se comprueba al aceptar la solicitud (422 SSRF_BLOCKED) y de nuevo en cada entrega: se resuelve el nombre de host, se comprueba cada dirección y la entrega solo se conecta a una dirección que superó la comprobación, de modo que no se contacta con un host que más tarde se resuelva a una dirección bloqueada. Cuando el nombre de host no se puede resolver, la entrega no se envía. En todas las rutas, una webhook_url vacía o formada solo por espacios equivale a no tener webhook. /compare no envía webhooks.\n\n**Suscripciones de la cuenta.** En lugar de (o además de) una webhook_url por solicitud, suscribe un endpoint a los eventos de la cuenta con `POST /webhook-subscriptions`: todos los eventos coincidentes de la cuenta se envían a él, sea cual sea la clave, la programación o la sesión del panel que los causó. Cada suscripción tiene su propio secreto (se devuelve una sola vez, al crearla), indica los tipos de evento que quiere y puede filtrar por `host` o `schedule_id`; una suscripción de producción recibe solo eventos de capturas de producción, y una suscripción de prueba solo eventos de capturas de prueba. Una clave de API lista y modifica solo las suscripciones de su propio entorno; la sesión del panel las ve todas. Sus entregas llevan `subscription_id` en el nivel superior y ningún enlace prefirmado (se eliminan package_url, package_url_expires_at, upgraded_proof_url y compare.package_url; monitor.change_detected lleva compare.compare_id): descarga el paquete con GET /captures/{capture_id}. Los eventos de captura también llevan `capture_id` en el nivel superior. El webhook-id de una entrega de suscripción se deriva de la suscripción, la captura (o la programación) y el evento. Se aplican las reglas de entrega y los reintentos que se describen más abajo. Además, una respuesta 410 desactiva la suscripción de inmediato (disabled_reason gone), las entregas que fallan durante 5 días sin ningún éxito (y al menos un ciclo completo de intentos fallidos) la desactivan (failing), y revocar la clave de API que la creó la desactiva (key_revoked); PATCH status \"active\" la reactiva. Cada evento se conserva también durante 30 días en `GET /events`.\n\n**Cabeceras**\n\nCada entrega se firma según la especificación [Standard Webhooks](https://www.standardwebhooks.com/), por lo que cualquier biblioteca oficial de Standard Webhooks puede verificarla:\n\n| Cabecera | Valor |\n| --- | --- |\n| `Content-Type` | `application/json` |\n| `webhook-id` | `msg_<delivery id>`: el mismo para el mismo evento de la misma captura, por lo que una entrega duplicada lleva el mismo id. Úsalo para eliminar duplicados: procesa cada webhook-id una sola vez. `GET /webhook-events/{capture_id}` devuelve el mismo id sin el prefijo `msg_` como `delivery_id` |\n| `webhook-timestamp` | Hora Unix, en segundos, en que se envió la entrega. Está cubierta por la firma |\n| `webhook-signature` | `v1,<base64>`: HMAC-SHA256 sobre `<webhook-id>.<webhook-timestamp>.<raw body>`. Durante una renovación del secreto de firma del webhook lleva dos entradas separadas por un espacio, `v1,<new> v1,<previous>`; acepta la entrega cuando coincida cualquiera de ellas |\n| `User-Agent` | `ProofSnap-Webhook/2.0` |\n\n**Verificar una entrega.** Verifica sobre los bytes exactos que recibiste, antes de analizarlos. Rechaza una entrega cuyo webhook-timestamp difiera de tu reloj en más de 5 minutos (las bibliotecas oficiales lo hacen) y elimina duplicados por webhook-id. La clave de firma es la parte del secreto que sigue a `whsec_`, decodificada de base64. `webhook_secret` es `whsec_` seguido de base64 estándar, la forma que esperan las bibliotecas oficiales, así que pásalo tal cual.\n\n```js\n// npm install standardwebhooks\nimport { Webhook } from \"standardwebhooks\";\nconst wh = new Webhook(process.env.PROOFSNAP_WEBHOOK_SECRET); // whsec_... (webhook_secret)\n// rawBody: the request body as received (string or Buffer), headers: the request headers\nconst event = wh.verify(rawBody, headers); // throws on a bad signature or a timestamp outside 5 minutes\n```\n\nSin biblioteca (Node):\n\n```js\nconst key = Buffer.from(secret.slice(\"whsec_\".length), \"base64\");\nconst signed = `${headers[\"webhook-id\"]}.${headers[\"webhook-timestamp\"]}.${rawBody}`;\nconst expected = crypto.createHmac(\"sha256\", key).update(signed).digest(\"base64\");\nconst ok = headers[\"webhook-signature\"].split(\" \").some((part) => {\n  const [version, sig] = part.split(\",\");\n  return version === \"v1\" && sig && sig.length === expected.length\n    && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));\n});\n// and: Math.abs(Date.now() / 1000 - Number(headers[\"webhook-timestamp\"])) <= 300\n```\n\n**Secreto de firma del webhook.** Cada clave de API tiene su propio secreto (`whsec_...`). Se muestra una sola vez, cuando creas la clave en el panel, en Desarrolladores (el campo `webhook_secret` de la respuesta que crea la clave). No se puede volver a leer más tarde: el listado de claves nunca lo devuelve. Si lo pierdes, o si puede haberse filtrado, genera uno nuevo en el panel, en Desarrolladores (Nuevo secreto de webhook en la clave). El nuevo secreto también se muestra una sola vez. **Solapamiento durante la rotación:** durante 24 horas tras una rotación (hasta `previous_secret_expires_at` en la respuesta de la rotación) cada entrega se firma con el secreto nuevo y con el anterior (dos entradas `v1,` en webhook-signature), así puedes cambiar tu endpoint al nuevo secreto sin perder entregas. Una segunda rotación mientras ese solapamiento sigue en curso se rechaza con 409 WEBHOOK_SECRET_ROTATION_PENDING y `error.details.previous_secret_expires_at` (cuándo puedes volver a rotar), de modo que nunca hay más de dos secretos activos a la vez y un secreto del que los receptores aún puedan depender nunca se retira antes de tiempo. Si un secreto se ha filtrado, revoca la clave y crea una nueva. Revocar la clave elimina ambos. Las entregas usan los secretos de la clave en el momento del envío, incluidas las entregas de trabajos iniciados antes de una rotación. Una solicitud de una sesión iniciada en el panel no tiene clave de API y, por tanto, tampoco secreto de firma del webhook, por lo que una `webhook_url` en ella se rechaza con 400 `WEBHOOK_REQUIRES_API_KEY` (capturas, lotes, ocultaciones, certificaciones de hash, creación o edición de una captura automática). Las ejecuciones programadas usan el secreto de la clave que creó la programación. Cuando la clave se ha revocado, o su secreto no se puede leer en el momento de la entrega, el evento no se envía y el intento se registra con el motivo (consúltalo con `GET /webhook-events/{capture_id}`).\n\n**Entrega.** Cada intento tiene un tiempo de espera de 10 segundos. Las redirecciones nunca se siguen: una respuesta 3xx cuenta como fallo, igual que una respuesta fuera de 2xx o un tiempo de espera agotado. El cuerpo de la respuesta no se lee. **Reintentos:** una entrega que agota el tiempo de espera, no puede conectarse o recibe una respuesta fuera de 2xx (excepto 410 Gone) se envía de nuevo unos 5 minutos, 30 minutos, 2 horas, 8 horas y 24 horas después (intentos 2 a 6, unas 35 horas en total), cada vez con el mismo `webhook-id` y un nuevo `webhook-timestamp`. Después, la entrega se abandona. Una entrega reintentada lleva la misma carga útil SIN el enlace de descarga de 24 horas (`package_url`, `package_url_expires_at`): descarga el paquete con GET /captures/{capture_id}. Las entregas de una captura borrada entretanto se descartan. Un fallo transitorio por nuestra parte al leer el secreto de firma se reintenta de la misma manera. Un 410 Gone, una redirección, una dirección bloqueada o una clave ausente o revocada no se reintentan. El cuerpo de un reintento puede diferir del primer intento (sin enlace de descarga): elimina duplicados por webhook-id, no por un hash del cuerpo. Responde rápido con un 2xx. El registro de intentos anota una clase de error genérica (connection_failed, timeout, blocked_address, redirect_not_followed, http_<status>), nunca el error de red sin procesar. certify.completed y redaction.completed se envían de forma asíncrona, poco después de devolver la respuesta. Cada intento se registra; consulta el registro con `GET /webhook-events/{capture_id}`.\n\n**Eventos**\n\n`capture.completed`: una captura (individual, elemento de un lote o ejecución de una programación por intervalo o cron) ha terminado. Las suscripciones de webhook de la cuenta también lo reciben para las capturas de referencia y de cambio de una programación on_change (la webhook_url propia de la programación recibe monitor.change_detected para ellas). `capture` tiene los mismos campos que un `GET /captures/{capture_id}` completado, más `timestamps.eidas` cuando se añadió un sello cualificado de tiempo electrónico, `schedule_id` (null fuera de una programación), `test_mode` y `completed_at`.\n\n```json\n{ \"event\": \"capture.completed\",\n  \"capture\": { \"capture_id\": \"cap_...\", \"status\": \"completed\", \"url\": \"https://example.com\", \"schedule_id\": null, \"test_mode\": false, \"captured_at\": \"...\", \"completed_at\": \"...\",\n    \"package_url\": \"https://...\", \"package_url_expires_at\": \"...\", \"verification_url\": \"https://...\",\n    \"manifest_hash\": \"sha256:...\", \"timestamps\": { \"bitcoin_ots\": { \"status\": \"pending_confirmation\", \"block_height\": null } },\n    \"c2pa\": { \"included\": true, \"signed_screenshot_sha256\": \"sha256:...\" }, \"file_count\": 21, \"size_bytes\": 1234567, \"metadata\": {} } }\n```\n\n`capture.failed`: no se pudo completar la captura. `error.message` es un motivo público breve (para un fallo inesperado \"The capture could not be completed.\"); los detalles internos del error nunca se envían. Esto incluye una ejecución que se bloqueó o agotó el tiempo: un barrido la marca como fallida unos 30 minutos después de iniciarse y entonces se envía capture.failed (mensaje \"Capture did not finish (timed out).\"). El mismo barrido envía certify.failed o redaction.failed para una certificación de hash o una ocultación que no terminó. Una ejecución que termina después de haberse notificado como fallida no se factura y no envía capture.completed.\n\n```json\n{ \"event\": \"capture.failed\",\n  \"capture\": { \"capture_id\": \"cap_...\", \"status\": \"failed\", \"url\": \"https://example.com\", \"schedule_id\": null, \"test_mode\": false, \"failed_at\": \"...\",\n    \"error\": { \"code\": \"CAPTURE_FAILED\", \"message\": \"...\" }, \"metadata\": {} } }\n```\n\n`certify.completed`: una certificación de hash ha terminado. `certificate` tiene los mismos campos que la respuesta síncrona de `POST /certify-hash`. Se entrega de forma asíncrona, poco después de devolver la respuesta.\n\n`certify.failed`: no se pudo completar una certificación de hash.\n\n```json\n{ \"event\": \"certify.failed\",\n  \"certificate\": { \"certify_id\": \"cert_...\", \"status\": \"failed\", \"failed_at\": \"...\",\n    \"error\": { \"code\": \"CERTIFY_FAILED\", \"message\": \"The certification could not be completed. Retry the request.\" } } }\n```\n\n`redaction.completed`: una ocultación ha terminado. `redaction` tiene los mismos campos que la respuesta de `POST /captures/{capture_id}/redact`.\n\n`redaction.failed`: no se pudo completar una ocultación. `error.code` es el código que habría devuelto la solicitud (por ejemplo REDACTION_LEAK_DETECTED), con `details` cuando los haya; una ocultación que el barrido marcó como fallida lleva REDACTION_FAILED.\n\n```json\n{ \"event\": \"redaction.failed\",\n  \"redaction\": { \"redaction_id\": \"red_...\", \"status\": \"failed\", \"redacted_from\": \"cap_...\", \"failed_at\": \"...\",\n    \"error\": { \"code\": \"REDACTION_LEAK_DETECTED\", \"message\": \"...\", \"details\": { \"files\": [\"page.html\"], \"rule_indexes\": [0], \"metadata_fields\": [], \"in_capture_url\": false } } } }\n```\n\n`monitor.change_detected`: una programación `on_change` detectó un cambio en el texto de la página y realizó una nueva captura.\n\n```json\n{ \"event\": \"monitor.change_detected\", \"schedule_id\": \"sch_...\",\n  \"before_capture_id\": \"cap_...\", \"after_capture_id\": \"cap_...\", \"url\": \"https://example.com\",\n  \"compare\": { \"package_url\": \"https://...\", \"diff_summary\": { } },\n  \"diff_status\": \"ok\", \"verdict\": \"changed\", \"text_verdict\": \"changed\", \"images_verdict\": \"unchanged\", \"metadata_verdict\": \"unchanged\",\n  \"detected_at\": \"...\", \"test_mode\": false }\n```\n\n`verdict`, `text_verdict`, `images_verdict` y `metadata_verdict` (desde 2026-10-14) reflejan los resultados por categoría de la comparación en compare.diff_summary (null sin comparación). Una programación con `alert_on: \"text\"` envía monitor.change_detected solo cuando text_verdict es changed (o cuando no hay resultado de comparación).\n\n`capture.anchor_confirmed` (solo suscripciones y GET /events): se confirmó el anclaje en Bitcoin de la prueba de OpenTimestamps del paquete, normalmente unas horas después de la captura.\n\n```json\n{ \"event\": \"capture.anchor_confirmed\", \"capture_id\": \"cap_...\", \"schedule_id\": null, \"url\": \"https://example.com\",\n  \"confirmed_at\": \"...\", \"bitcoin_block_height\": 968549, \"upgraded_proof_available\": true, \"test_mode\": false }\n```\n\n`schedule.paused` (solo suscripciones y GET /events): ProofSnap pausó una programación. `reason` es `no_billing` (sin acceso a la API), `plan_downgrade` (el plan permite menos programaciones, no permite on_change o no permite trabajo nuevo), `failures` (cinco ejecuciones fallidas seguidas), `key_revoked` o `invalid_cron`. Una pausa que haces tú no es un evento.\n\n```json\n{ \"event\": \"schedule.paused\", \"schedule_id\": \"sch_...\", \"url\": \"https://example.com\", \"reason\": \"failures\",\n  \"paused_at\": \"...\", \"test_mode\": false }\n```\n\n`diff_status` es `ok` (compare contiene la comparación), `no_baseline` (no hay ninguna captura anterior con la que comparar) o `failed` (la comparación falló; compare es null)."
    },
    {
      "name": "Público",
      "description": "Operaciones que no necesitan clave de API."
    },
    {
      "name": "Importación de paquetes",
      "description": "Importa en tu cuenta paquetes de pruebas de ProofSnap existentes (archivos ZIP generados por la extensión de ProofSnap: capturas web, sesiones de varias pestañas, certificaciones de archivos individuales y combinadas; o por esta API: capturas, certificaciones, comparaciones, ocultaciones), para que se gestionen como tus capturas de la API: se conservan 10 años y están disponibles para retención legal, asuntos y exportaciones.\n\nSolo planes Legal (legal_solo, legal_seats), con una clave de API de producción o una sesión del panel con facturación vinculada (una clave de prueba, una sesión sin facturación y el sandbox reciben 403 IMPORT_NOT_ALLOWED). Una dirección de correo electrónico no confirmada recibe 403 EMAIL_NOT_VERIFIED, y una suscripción con pagos vencidos, 402 PAYMENT_REQUIRED. Una importación no es una captura: no cuenta para los límites mensuales ni tiene tarifa por importación; se factura solo como datos almacenados.\n\n**Flujo.** 1. `POST /imports` con el nombre del archivo y su tamaño exacto en bytes (como máximo 2 GiB): la respuesta contiene una URL prefirmada por cada parte de 32 MiB (la última parte es menor), válida durante 1 hora. 2. Envía con PUT los bytes de cada parte a su URL y guarda la cabecera de respuesta `ETag` de cada una. 3. `POST /imports/{import_id}/complete` con cada número de parte y su ETag. 4. Consulta `GET /imports/{import_id}` hasta que el estado sea definitivo.\n\n**Estados.** `uploading` (a la espera de las subidas de las partes y de la llamada complete; una subida no completada en 24 horas pasa a `expired`), `verifying` (se está comprobando el paquete), `accepted` (se verificó: `capture_id` es la nueva captura, con el mismo ID que la importación), `rejected` (no se verificó: `reasons` enumera los códigos; no se conserva nada salvo el registro de la importación), `duplicate` (tu cuenta ya contiene una captura con el mismo manifiesto: `duplicate_of`), `expired`.\n\n**Verificación.** Estricta: la firma RSA sobre manifest.json, cada archivo listado presente con el SHA-256 listado (y SHA-512 y tamaño cuando se indican), ningún archivo que el manifiesto no liste (excepto los archivos que añade el sistema operativo, como .DS_Store, y las pruebas derivadas del manifiesto), al menos un archivo con hash, un ID de prueba coherente, la cadena de hash del registro forense recalculada, la prueba de OpenTimestamps frente al manifiesto (sin conexión) y el sello cualificado de tiempo electrónico eIDAS frente a la raíz fijada de Disig con los datos de validación del paquete. La estructura ZIP se comprueba antes de leer nada (sin cifrado, sin ZIP64, sin enlaces, nombres seguros, sin nombres duplicados, tamaños y CRC-32 de cada entrada). Se rechaza un paquete de prueba de la API (con marca de agua). Los paquetes de la API sellados antes de que se calculara el hash de evidence.pdf se aceptan, con ese archivo listado en `verification.checks.files.unhashed`.\n\n**Niveles** (`summary`): `integrity` (verified o failed), `time_anchor` (eidas_qualified, eidas_qualified_revocation_unknown, ots_bitcoin_attested_unconfirmed, ots_pending, none) y `provenance`: para paquetes de la API, `issued_by_proofsnap` (el manifiesto está en el registro de emisión de paquetes de la API) o `unverified`; para paquetes de la extensión, `registered_by_extension_account` (un usuario de la extensión con sesión iniciada registró ese manifiesto cuando se creó) o `not_registered`; `unknown` cuando no se pudo hacer la consulta. La procedencia nunca hace fallar una importación.\n\n**La captura.** Una importación aceptada es una captura con `source` \"import\": se puede descargar (el ZIP subido, byte a byte) y verificar (POST /captures/{capture_id}/verify vuelve a ejecutar las mismas comprobaciones), se puede poner bajo retención legal, mover a la papelera y borrar definitivamente, aparece en los asuntos (cuando se importa con `matter_id`, el registro de custodia del asunto registra `package.imported`) y en las exportaciones, y se conserva 10 años desde la importación. Su `captured_at` es la hora que declara el propio paquete (`import.captured_at_basis` \"package_claim\"), nunca una hora observada por ProofSnap; `created_at` es cuando se creó la subida. Se rechazan las comparaciones y ocultaciones de un paquete importado (422 IMPORT_NOT_SUPPORTED).\n\n**Límites.** Como máximo 3 importaciones en curso (subiéndose o en verificación) por cuenta (409 IMPORT_LIMIT), 50 importaciones creadas por cuenta y día UTC (429 RATE_LIMITED, `details.action` \"import_create\"; cuenta cada solicitud de creación, incluida una que se rechace después) y el límite de frecuencia por minuto del plan en create y complete. Volumen de importación: como máximo 5 GiB por día UTC y 20 GiB por mes UTC de tamaños de paquete anunciados por cuenta, contados en create (y en complete para una subida más antigua); por encima, 429 IMPORT_DAILY_BYTES_LIMIT o IMPORT_MONTHLY_BYTES_LIMIT con `Retry-After`; una subida cancelada no cuenta."
    }
  ],
  "paths": {
    "/captures": {
      "post": {
        "tags": [
          "Capturas"
        ],
        "operationId": "createCapture",
        "summary": "Capturar una URL",
        "description": "Acepta la captura y devuelve 202 de inmediato; la captura se ejecuta en segundo plano. Consulta `GET /captures/{capture_id}` periódicamente o pasa `options.webhook_url`. El nombre de host de la URL se resuelve y se comprueba antes de aceptar la captura.\n\nLímite de frecuencia por cuenta en ventanas de 60 segundos (todas las claves de API de la cuenta y su sesión del panel comparten una misma ventana). Una vez ejecutado el limitador, las respuestas de esta ruta incluyen los encabezados `RateLimit-Policy` y `RateLimit` (basados en el borrador IETF draft-ietf-httpapi-ratelimit-headers), además de los heredados `X-RateLimit-Limit`, `X-RateLimit-Remaining` y `X-RateLimit-Reset`. Una respuesta 429 añade `Retry-After`.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CaptureRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Aceptado. Una reproducción idempotente de una solicitud anterior con la misma Idempotency-Key también devuelve 202, con `Idempotent-Replay: true` y el cuerpo original.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              },
              "Idempotent-Replay": {
                "$ref": "#/components/headers/IdempotentReplay"
              },
              "X-Quota-Limit": {
                "$ref": "#/components/headers/QuotaLimit"
              },
              "X-Quota-Remaining": {
                "$ref": "#/components/headers/QuotaRemaining"
              },
              "X-Quota-Used": {
                "$ref": "#/components/headers/QuotaUsed"
              },
              "X-Quota-Cap": {
                "$ref": "#/components/headers/QuotaCap"
              },
              "X-Quota-Org-Limit": {
                "$ref": "#/components/headers/QuotaOrgLimit"
              },
              "X-Quota-Org-Used": {
                "$ref": "#/components/headers/QuotaOrgUsed"
              },
              "X-Sandbox-Limit": {
                "$ref": "#/components/headers/SandboxLimit"
              },
              "X-Sandbox-Remaining": {
                "$ref": "#/components/headers/SandboxRemaining"
              },
              "X-Sandbox-Used": {
                "$ref": "#/components/headers/SandboxUsed"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CaptureAccepted"
                },
                "example": {
                  "capture_id": "cap_a1B2c3D4e5F6g7H8",
                  "status": "processing",
                  "url": "https://example.com",
                  "poll_path": "/v1/captures/cap_a1B2c3D4e5F6g7H8",
                  "message": "Capture accepted. Poll GET /v1/captures/{capture_id} (or use webhook_url) for completion.",
                  "metadata": {}
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST, MISSING_FIELD, INVALID_URL, INVALID_TIMESTAMP_TYPE, INVALID_VIEWPORT, METADATA_TOO_LARGE o INVALID_EVIDENCE_PACK_OPTIONS. WEBHOOK_REQUIRES_API_KEY: webhook_url enviado desde una sesión del panel (los webhooks necesitan una clave de API).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "QUOTA_EXCEEDED (máximo mensual o asignación del sandbox; lleva las cabeceras X-Quota-* o X-Sandbox-*) o PAYMENT_REQUIRED.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "403": {
            "description": "EIDAS_REQUIRES_LIVE_KEY: include_eidas con una clave de prueba (una clave ps_live_ de una cuenta sin una cuenta de facturación vinculada funciona como clave de prueba). EMAIL_NOT_VERIFIED: una sesión del panel cuya dirección de correo electrónico no está confirmada. PLAN_ARCHIVE_ONLY: el plan solo conserva el archivo y no crea trabajo nuevo. PLAN_API_READ_ONLY: una clave de API de una cuenta Website Archive (su clave es de solo lectura; usa el panel). PLAN_API_READ_ONLY con details.reason \"session_outside_dashboard\": una sesión del panel de una cuenta Website Archive cuya solicitud no tiene encabezado Origin o tiene uno que no es una dirección del panel de ProofSnap (en ese plan, el trabajo nuevo solo se crea en el panel; es una restricción blanda, no una barrera de seguridad).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "409": {
            "description": "IDEMPOTENCY_IN_PROGRESS.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "422": {
            "description": "SSRF_BLOCKED, TARGET_UNREACHABLE o IDEMPOTENCY_KEY_REUSED.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "429": {
            "description": "RATE_LIMITED: demasiadas solicitudes en la ventana actual de 60 segundos. `error.details.retry_after_seconds` repite Retry-After. RateLimit indica r=0 y los segundos que faltan para que se restablezca la ventana. Además RATE_LIMITED, para una sesión del panel de Website Archive: su tope diario de 100 unidades por día UTC, compartido por las capturas individuales y por lotes (una unidad por URL), compare, redact y certify-hash (una unidad por solicitud); una solicitud que no cabe se rechaza entera (details.daily_limit, details.remaining, details.action \"wa_session_captures\"; Retry-After y details.retry_after_seconds dan los segundos que faltan para el siguiente día UTC, y se omiten las cabeceras RateLimit y X-RateLimit-* por minuto). Un rechazo breve con solo details.retry_after_seconds y Retry-After: 1 indica una actualización concurrente del contador: vuelve a intentarlo. Un rechazo que no es culpa del llamante devuelve las unidades; el trabajo aceptado con 202 que falla después conserva su unidad. Es un control flexible, no una barrera de seguridad. Una captura individual cuya Idempotency-Key se sirve desde el almacén (una repetición, 409 o 422) se sirve antes del tope diario y no cuesta ninguna unidad.\n\nCONCURRENT_CAPTURE_LIMIT: hay demasiadas capturas de la cuenta en ejecución a la vez (developer 10, startup 25, growth 100, legal_solo y legal_seats 25, custom 200, website_archive 25, sandbox 5); `details.limit`, `details.in_flight`, `details.requested` y Retry-After indican cuándo volver a intentarlo.\n\nTEST_MODE_DAILY_LIMIT: el trabajo en modo de prueba (una clave de prueba, o una sesión sin facturación vinculada) está limitado a 200 unidades por cuenta y día UTC; `details.used`, `details.limit`, `details.reset_at`, las cabeceras `X-Test-Daily-*` y Retry-After.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "RATE_LIMITED",
                    "message": "Too many requests. See Retry-After header.",
                    "details": {
                      "retry_after_seconds": 42
                    }
                  },
                  "request_id": "..."
                }
              }
            }
          },
          "500": {
            "description": "CAPTURE_FAILED (no se pudo iniciar la captura; details.capture_id) o INTERNAL_ERROR.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/EidasOrAuthUnavailable"
          }
        }
      },
      "get": {
        "tags": [
          "Capturas"
        ],
        "operationId": "listCaptures",
        "summary": "Listar tus capturas",
        "description": "Filas que has creado, de la más reciente a la más antigua, en todos los estados (incluidas las eliminadas, la papelera; filtra con status). Cada fila lleva source, redacted_from, label y compared, para que se pueda mostrar su tipo sin volver a leerla. Una captura realizada para una empresa solo se enumera mientras aún puedas actuar sobre ella (ocupas un puesto de esa empresa o eres su propietario); por tanto, una página puede contener menos filas que limit, y next_cursor continúa tras la última fila leída. Se incluyen certificados, comparaciones y ocultaciones.\n\nBúsqueda (solo en tu propia lista): url, host o schedule_id, combinados con from / to y status, encuentran el registro de una página en un día concreto. Un filtro incorrecto, dos de url / host / schedule_id, o un from que no sea anterior a to devuelve 400 MALFORMED_REQUEST.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Tamaño de página, de 1 a 200 (50 por defecto). Un valor fuera del intervalo devuelve 400 MALFORMED_REQUEST.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "description": "next_cursor de la página anterior (formato created_at|capture_id, o completed_at|capture_id con order=completed_at). Pásalo sin cambios.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Solo las filas en este estado: processing, completed, failed o deleted (papelera). Cualquier otro valor da 400 MALFORMED_REQUEST.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "url",
            "in": "query",
            "required": false,
            "description": "Solo las capturas de esta URL exacta, tal como se capturó (http o https). Como máximo uno de url, host y schedule_id.",
            "schema": {
              "type": "string",
              "maxLength": 2048
            }
          },
          {
            "name": "host",
            "in": "query",
            "required": false,
            "description": "Solo las capturas cuya URL está en este nombre de host (sin distinguir mayúsculas de minúsculas, por ejemplo www.example.com; los subdominios son hosts distintos). Como máximo uno de url, host y schedule_id. Las filas creadas antes del 2026-10-05 llevan el host una vez ejecutado el backfill.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "schedule_id",
            "in": "query",
            "required": false,
            "description": "Solo las capturas hechas por esta programación. Como máximo uno de url, host y schedule_id.",
            "schema": {
              "type": "string",
              "pattern": "^sch_[A-Za-z0-9]{8,32}$"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "Creado en este momento o después: una fecha (YYYY-MM-DD, medianoche UTC) o una marca de tiempo ISO 8601.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "Creado antes de este momento (excluido): una fecha (YYYY-MM-DD, medianoche UTC) o una marca de tiempo ISO 8601. Debe ser posterior a from. Con schedule_id o url, la fila más reciente anterior a un momento es la versión vigente en ese momento.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "order",
            "in": "query",
            "required": false,
            "description": "created_at (predeterminado: primero las creadas más recientemente) o completed_at (primero las completadas más recientemente; solo con status=completed, si no 400 MALFORMED_REQUEST). Con completed_at, from y to acotan completed_at, y next_cursor tiene la forma completed_at|capture_id. Funciona con url, host y schedule_id.",
            "schema": {
              "type": "string",
              "enum": [
                "created_at",
                "completed_at"
              ],
              "default": "created_at"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Una página de capturas.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "captures": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "capture_id": {
                            "type": "string"
                          },
                          "url": {
                            "type": "string",
                            "nullable": true
                          },
                          "status": {
                            "type": "string",
                            "nullable": true
                          },
                          "created_at": {
                            "type": "string",
                            "nullable": true
                          },
                          "completed_at": {
                            "type": "string",
                            "nullable": true,
                            "description": "Cuándo se completó la captura (null durante el procesamiento o tras un fallo)."
                          },
                          "batch_id": {
                            "type": "string",
                            "nullable": true
                          },
                          "schedule_id": {
                            "type": "string",
                            "nullable": true
                          },
                          "test_mode": {
                            "type": "boolean",
                            "nullable": true
                          },
                          "external_archive": {
                            "allOf": [
                              {
                                "$ref": "#/components/schemas/ExternalArchive"
                              }
                            ],
                            "description": "Solo filas completed. Igual que external_archive de GET /captures/{capture_id}."
                          },
                          "label": {
                            "type": "string",
                            "nullable": true,
                            "description": "La etiqueta de la fila (certificados: la etiqueta indicada; ocultaciones: de qué captura ocultan datos)."
                          },
                          "compared": {
                            "type": "array",
                            "nullable": true,
                            "items": {
                              "type": "string"
                            },
                            "description": "Solo comparaciones: los ID de las dos capturas de origen."
                          },
                          "source": {
                            "type": "string",
                            "nullable": true,
                            "description": "Cómo se creó la fila: api (una captura web), schedule, certify-hash (un certificado), compare (una comparación) o redact (una ocultación); null en filas anteriores al campo."
                          },
                          "redacted_from": {
                            "type": "string",
                            "nullable": true,
                            "description": "Solo ocultaciones: la captura a partir de la cual se realizó la ocultación."
                          },
                          "captured_at": {
                            "type": "string",
                            "nullable": true,
                            "format": "date-time",
                            "description": "Cuándo se capturó la página (filas completadas). En una ocultación, la hora de captura de su captura de origen (redacted_at es cuándo se hizo la ocultación)."
                          },
                          "redacted_at": {
                            "type": "string",
                            "format": "date-time",
                            "description": "Solo ocultaciones: cuándo se realizó la ocultación. El captured_at de una ocultación es la hora de captura de su captura de origen."
                          },
                          "compared_at": {
                            "type": "string",
                            "nullable": true,
                            "format": "date-time",
                            "description": "Solo comparaciones: cuándo se hizo la comparación. Su captured_at contiene el mismo valor (se mantiene para lectores más antiguos); usa compared_at."
                          },
                          "manifest_hash": {
                            "type": "string",
                            "nullable": true,
                            "description": "sha256:<hex> del manifest.json del paquete (filas completadas)."
                          },
                          "private_note": {
                            "type": "string",
                            "nullable": true,
                            "description": "Tu nota privada sobre la captura (consulta PrivateNote), nunca forma parte del paquete de pruebas."
                          },
                          "legal_hold": {
                            "type": "boolean",
                            "description": "La captura está bajo retención legal (no se elimina mientras sea true; se libera automáticamente cuando la suscripción ha finalizado y han pasado sus 30 días). Consulta PATCH /captures/{capture_id}."
                          }
                        }
                      }
                    },
                    "next_cursor": {
                      "type": "string",
                      "description": "null cuando esta página tiene menos filas que limit.",
                      "nullable": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST: cursor, status (uno de processing, completed, failed, deleted), limit (de 1 a 200) o filtro de búsqueda (url, host, schedule_id, from, to; como máximo uno de url / host / schedule_id; from anterior a to) no válidos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/captures/batch": {
      "post": {
        "tags": [
          "Capturas"
        ],
        "operationId": "createBatch",
        "summary": "Capturar varias URL",
        "description": "Inicia una captura por URL con las mismas opciones. Cada captura es independiente: consulta cada capture_id o recibe un webhook por captura. Consume una unidad del límite de frecuencia y una unidad del límite mensual por URL. Un lote con más URL de las que permite una ventana del límite de frecuencia nunca puede completarse, por lo que se rechaza con 400 RATE_LIMIT_COST_TOO_HIGH en lugar de 429. Divídelo en solicitudes más pequeñas. La repetición de un lote completado (misma Idempotency-Key y mismo cuerpo) se sirve antes de aplicar el límite de frecuencia y no consume ninguna unidad del límite de frecuencia.\n\nLímite de frecuencia por cuenta en ventanas de 60 segundos (todas las claves de API de la cuenta y su sesión del panel comparten una misma ventana). Una vez ejecutado el limitador, las respuestas de esta ruta incluyen las cabeceras `RateLimit-Policy` y `RateLimit` (basadas en el borrador de la IETF draft-ietf-httpapi-ratelimit-headers) más las heredadas `X-RateLimit-Limit`, `X-RateLimit-Remaining` y `X-RateLimit-Reset`. Un 429 añade `Retry-After`.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchRequest"
              },
              "example": {
                "urls": [
                  "https://example.com/a",
                  "https://example.com/b"
                ],
                "options": {
                  "case_reference": "Smith v. Jones"
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Aceptado. Una reproducción idempotente de un lote completado con la misma Idempotency-Key y el mismo cuerpo también devuelve 202, con `Idempotent-Replay: true`; se sirve antes de aplicar el límite de frecuencia, por lo que no consume ninguna unidad del límite de frecuencia.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              },
              "Idempotent-Replay": {
                "$ref": "#/components/headers/IdempotentReplay"
              },
              "X-Quota-Limit": {
                "$ref": "#/components/headers/QuotaLimit"
              },
              "X-Quota-Remaining": {
                "$ref": "#/components/headers/QuotaRemaining"
              },
              "X-Quota-Used": {
                "$ref": "#/components/headers/QuotaUsed"
              },
              "X-Quota-Cap": {
                "$ref": "#/components/headers/QuotaCap"
              },
              "X-Quota-Org-Limit": {
                "$ref": "#/components/headers/QuotaOrgLimit"
              },
              "X-Quota-Org-Used": {
                "$ref": "#/components/headers/QuotaOrgUsed"
              },
              "X-Sandbox-Limit": {
                "$ref": "#/components/headers/SandboxLimit"
              },
              "X-Sandbox-Remaining": {
                "$ref": "#/components/headers/SandboxRemaining"
              },
              "X-Sandbox-Used": {
                "$ref": "#/components/headers/SandboxUsed"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BatchResult"
                }
              }
            }
          },
          "400": {
            "description": "INVALID_BATCH (details: reason y received, o index, url y un objeto reason anidado para la URL no válida), MALFORMED_REQUEST o un error de opciones. WEBHOOK_REQUIRES_API_KEY: webhook_url enviado desde una sesión del panel (los webhooks necesitan una clave de API). RATE_LIMIT_COST_TOO_HIGH: más URL de las que permite una ventana del límite de frecuencia; details { reason, max_per_window, window_seconds, requested }.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "QUOTA_EXCEEDED o PAYMENT_REQUIRED. Un lote también se rechaza con QUOTA_EXCEEDED cuando no se puede leer el uso mensual en ese momento; vuelve a intentarlo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "403": {
            "description": "EIDAS_REQUIRES_LIVE_KEY: include_eidas con una clave de prueba. EMAIL_NOT_VERIFIED: una sesión del panel cuya dirección de correo electrónico no está confirmada. PLAN_ARCHIVE_ONLY: el plan solo conserva el archivo y no crea trabajo nuevo. PLAN_API_READ_ONLY: una clave de API de una cuenta Website Archive (su clave es de solo lectura; usa el panel). PLAN_API_READ_ONLY con details.reason \"session_outside_dashboard\": una sesión del panel de una cuenta Website Archive cuya solicitud no tiene encabezado Origin o tiene uno que no es una dirección del panel de ProofSnap (en ese plan, el trabajo nuevo solo se crea en el panel; es una restricción blanda, no una barrera de seguridad).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "409": {
            "description": "IDEMPOTENCY_IN_PROGRESS.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "422": {
            "description": "IDEMPOTENCY_KEY_REUSED.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "429": {
            "description": "RATE_LIMITED: demasiadas solicitudes en la ventana actual de 60 segundos. `error.details.retry_after_seconds` repite Retry-After. RateLimit indica r=0 y los segundos que faltan para que se restablezca la ventana. Además RATE_LIMITED, para una sesión del panel de Website Archive: su tope diario de 100 unidades por día UTC, compartido por las capturas individuales y por lotes (una unidad por URL), compare, redact y certify-hash (una unidad por solicitud); una solicitud que no cabe se rechaza entera (details.daily_limit, details.remaining, details.action \"wa_session_captures\"; Retry-After y details.retry_after_seconds dan los segundos que faltan para el siguiente día UTC, y se omiten las cabeceras RateLimit y X-RateLimit-* por minuto). Un rechazo breve con solo details.retry_after_seconds y Retry-After: 1 indica una actualización concurrente del contador: vuelve a intentarlo. Un rechazo que no es culpa del llamante devuelve las unidades; el trabajo aceptado con 202 que falla después conserva su unidad. Es un control flexible, no una barrera de seguridad.\n\nCONCURRENT_CAPTURE_LIMIT: ninguna URL del lote pudo iniciarse porque la cuenta ya tiene en ejecución tantas capturas como se le permite (developer 10, startup 25, growth 100, legal_solo y legal_seats 25, custom 200, website_archive 25, sandbox 5); `details.limit`, `details.in_flight`, `details.requested` y Retry-After. Cuando solo caben algunas, el lote se acepta con 202 y enumera las restantes en `not_admitted`.\n\nTEST_MODE_DAILY_LIMIT: el trabajo en modo de prueba (una clave de prueba, o una sesión sin facturación vinculada) está limitado a 200 unidades por cuenta y día UTC, una por URL; `details.used`, `details.limit`, `details.reset_at`, las cabeceras `X-Test-Daily-*` y Retry-After.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "RATE_LIMITED",
                    "message": "Too many requests. See Retry-After header.",
                    "details": {
                      "retry_after_seconds": 42
                    }
                  },
                  "request_id": "..."
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/EidasOrAuthUnavailable"
          }
        }
      }
    },
    "/captures/hosts": {
      "get": {
        "tags": [
          "Capturas"
        ],
        "operationId": "listCaptureHosts",
        "summary": "Listar los nombres de host que has capturado",
        "description": "Cada nombre de host de tu propia lista de capturas, con el número de capturas y la hora de la última captura por host, para un filtro por dominio: pasa un host de vuelta como GET /captures?host=. count es el número de filas que devuelve GET /captures?host=<host> sin status (entre las capturas leídas): capturas de prueba y de producción por igual, todos los estados, incluidas las de la papelera (status deleted), tal como las muestra la lista; se excluyen las capturas borradas definitivamente y las filas sin URL de página (certificados). Una captura cuenta en cuanto lleva su nombre de host almacenado (las capturas creadas antes del 2026-10-05 lo llevan una vez ejecutado el backfill). Una captura de empresa cuenta solo mientras todavía puedas actuar sobre ella (ocupas un puesto de esa empresa o eres su propietario). host está en minúsculas. Ordenado por count (de mayor a menor) y después por host (de la A a la Z). Como máximo 1000 hosts. Se leen las 5000 capturas más recientes; truncated es true cuando hay más de 1000 hosts o capturas más antiguas que esas 5000, por lo que puede faltar un host capturado solo antes de ellas. La lista se calcula como máximo una vez por cuenta cada 30 segundos y puede tener hasta 30 segundos de antigüedad (algo más mientras otra solicitud de la cuenta la está calculando). Calcularla cuesta una unidad de tu límite de frecuencia por minuto; una respuesta servida desde esa caché no cuesta nada. Disponible en todos los planes, para claves de API y la sesión del panel.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Los nombres de host.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "hosts",
                    "truncated"
                  ],
                  "properties": {
                    "hosts": {
                      "type": "array",
                      "maxItems": 1000,
                      "items": {
                        "type": "object",
                        "required": [
                          "host",
                          "count",
                          "last_captured_at"
                        ],
                        "properties": {
                          "host": {
                            "type": "string",
                            "example": "www.example.com",
                            "description": "Nombre de host en minúsculas, el valor que admite GET /captures?host=."
                          },
                          "count": {
                            "type": "integer",
                            "minimum": 1,
                            "example": 12,
                            "description": "Capturas de este host entre las capturas leídas: las filas que GET /captures?host= devuelve sin status, incluidas las que están en la papelera."
                          },
                          "last_captured_at": {
                            "type": "string",
                            "format": "date-time",
                            "nullable": true,
                            "example": "2026-10-05T07:48:38Z",
                            "description": "Hora de la última captura de este host (cuando se capturó la página o, si no, cuando se solicitó la captura), UTC, al segundo."
                          }
                        }
                      }
                    },
                    "truncated": {
                      "type": "boolean",
                      "description": "true cuando la lista puede estar incompleta: más de 1000 hosts, o más capturas que las 5000 más recientes que se leyeron."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST: scope no admitido (solo capturas propias).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "description": "RATE_LIMITED: se agotó el límite de frecuencia por minuto (calcular la lista cuesta una unidad), o la lista de tu cuenta la está calculando otra solicitud y no hay ningún resultado anterior guardado; vuelve a intentarlo tras Retry-After segundos.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "RATE_LIMITED",
                    "message": "Too many requests. See Retry-After header.",
                    "details": {
                      "retry_after_seconds": 42
                    }
                  },
                  "request_id": "..."
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/captures/{capture_id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CaptureId"
        }
      ],
      "get": {
        "tags": [
          "Capturas"
        ],
        "operationId": "getCapture",
        "summary": "Obtener una captura",
        "description": "Estado y, una vez completado, un package_url nuevo válido durante un máximo de 24 horas (consulta package_url_expires_at). Funciona con ID de capturas, certificados, comparaciones y ocultaciones. Lo pueden leer la cuenta que lo creó, los miembros del asunto al que pertenece y el propietario de la empresa para la que se hizo (si la suscripción de la empresa ha finalizado, hasta que se cierre su ventana de descarga).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "event",
            "in": "query",
            "required": false,
            "description": "Envía download cuando estés descargando el paquete: en una captura de un asunto, esto registra capture.downloaded en el registro de accesos del asunto. Las simples consultas de estado deben omitirlo.",
            "schema": {
              "type": "string",
              "enum": [
                "download"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "La captura.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CaptureResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "FORBIDDEN: la captura no es tuya.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "CAPTURE_NOT_FOUND: ID desconocido o con formato incorrecto, captura eliminada, o no se encontró el archivo del paquete.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      },
      "delete": {
        "tags": [
          "Capturas"
        ],
        "operationId": "deleteCapture",
        "summary": "Eliminar una captura",
        "description": "Predeterminado: mueve la captura a la papelera. Se puede restaurar con PATCH hasta restorable_until (30 días); después se purga. Con permanent=true, el paquete almacenado se borra en ese momento y el registro se reduce a una marca de eliminación (tombstone); no se puede deshacer. Ninguna de las dos opciones elimina los paquetes que ya hayas descargado ni la prueba de OpenTimestamps. Una captura en un asunto abierto o archivado se quita primero del asunto (se anota en su registro de la cadena de custodia) cuando quien llama puede abrir ese asunto; quien no puede (su autor después de haber sido quitado del asunto) recibe un rechazo con 409 MATTER_LINKED, tanto para la papelera como para permanent=true, hasta que el propietario del asunto la quite del asunto; una captura en un asunto sellado no se puede eliminar. Una captura que sigue en proceso no se puede eliminar (409 CAPTURE_PROCESSING); elimínala cuando se haya completado o haya fallado. Eliminar una captura que nunca se completó devuelve su admisión mensual.\n\nPeriodo de gracia tras liberar una retención legal: durante 14 días después de liberar una retención legal (`deletion_blocked_until` en GET), la captura no se elimina de forma permanente (409 CAPTURE_HOLD_GRACE con `details.until`), ni por una persona ni por la purga de la papelera o la conservación (la eliminación de la cuenta y de la empresa tras el fin de la suscripción no la espera). Se permite moverla a la papelera; su purga espera al final del periodo de gracia. Una eliminación permanente levanta primero cualquier legal hold de S3 Object Lock que quede en los objetos almacenados; cuando el almacenamiento lo rechaza, la solicitud devuelve 409 CAPTURE_LEGAL_HOLD (\"still locked in storage\", reintenta más tarde) y no se elimina nada. Cada envío a la papelera, restauración y eliminación permanente se registra en la cadena de eventos de captura de la cuenta (GET /captures/events). Una ocultación o comparación cuya captura de origen está sujeta a retención legal devuelve `source_under_legal_hold: true` cuando se envía a la papelera o se elimina.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "permanent",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "true"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Eliminado. Papelera: { capture_id, status: \"deleted\", restorable: true, restorable_until }. Permanente: { capture_id, status: \"deleted\", permanent: true }. Ya estaba en la papelera: { capture_id, status }.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "capture_id": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string"
                    },
                    "restorable": {
                      "type": "boolean"
                    },
                    "restorable_until": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "permanent": {
                      "type": "boolean"
                    },
                    "source_under_legal_hold": {
                      "type": "boolean",
                      "description": "Solo presente (true) cuando se trata de una ocultación o comparación hecha a partir de una captura que está bajo retención legal."
                    },
                    "deletion_blocked_until": {
                      "type": "string",
                      "format": "date-time",
                      "description": "Papelera durante el periodo de gracia tras la liberación: el vaciado de la papelera espera hasta esta hora."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "FORBIDDEN: la captura no es tuya. FORBIDDEN también cuando la captura pertenece a un asunto que no está sellado y quien llama (el propietario de la empresa que actúa sobre la captura de un puesto, no su autor) no puede abrir ese asunto. PLAN_API_READ_ONLY: una clave de API de una cuenta Website Archive (su clave es de solo lectura; usa el panel).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "CAPTURE_NOT_FOUND.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "MATTER_SEALED: la captura es un anexo de un asunto sellado. MATTER_LINKED: la captura es un anexo de un asunto al que quien llama ya no pertenece; el propietario del asunto debe quitarla primero. CAPTURE_PROCESSING: la captura aún se está procesando (error.details.status). CAPTURE_LEGAL_HOLD: la captura está bajo retención legal y nadie puede eliminarla (ni moverla a la papelera ni borrarla definitivamente). CAPTURE_HOLD_GRACE: una eliminación definitiva dentro del periodo de gracia posterior al levantamiento de la retención legal de la captura (LEGAL_HOLD_RELEASE_GRACE_DAYS, 14 días); error.details.until indica cuándo será posible. Durante el periodo de gracia se permite moverla a la papelera.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "INTERNAL_ERROR: no se pudo borrar el paquete almacenado; no se eliminó nada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      },
      "patch": {
        "tags": [
          "Capturas"
        ],
        "operationId": "restoreCapture",
        "summary": "Restaurar una captura eliminada, establecer su nota privada, o aplicar o levantar su retención legal",
        "description": "Restaura una captura desde la papelera. Si se quitó de un asunto al eliminarla, se vuelve a añadir cuando ese asunto sigue abierto y el llamante aún puede abrirlo; matter_id y matter_relinked solo aparecen en la respuesta en ese caso (matter_id null cuando no se produjo la nueva vinculación). Un llamante que ya no puede abrir ese asunto recupera la captura sin él, no se escribe nada en el registro de custodia del asunto y la respuesta no menciona el asunto. Una captura que no está en la papelera se devuelve sin cambios.\n\nNota privada: un cuerpo con `private_note` (una cadena de como máximo 200 caracteres, o null para borrarla) establece en su lugar la nota privada de la captura y no hace nada más; no se puede combinar con `action`. La nota se guarda solo en el registro de la captura, fuera del paquete de pruebas sellado, por lo que cambiarla nunca altera las pruebas ni sus hashes. Solo quien realizó la captura puede establecerla (el propietario de la empresa y los miembros del asunto reciben 403), en una captura en cualquier estado excepto una eliminada definitivamente (404). La respuesta es { capture_id, status, private_note }.\n\nRetención legal: un cuerpo `{ \"action\": \"legal_hold\", \"enabled\": true|false, \"reason\"?: string }` aplica (true) o levanta (false) la retención legal de la captura y no hace nada más. Una captura bajo retención legal no se elimina: DELETE (papelera o definitiva) devuelve 409 CAPTURE_LEGAL_HOLD para todos, y ni el vaciado de la papelera ni la eliminación por fin del periodo de conservación la tocan; GET muestra `storage.retention.earliest_deletion_at` null. Una retención solo se mantiene mientras la cuenta tiene un plan que conserva datos (cualquier plan de pago, Archive only incluido; para una captura de empresa: la empresa está activa o se conserva como archivo de empresa). Cuando la suscripción termina, las capturas siguen disponibles durante 30 días; si para entonces no se ha comprado un plan de ese tipo, todas las retenciones legales de la cuenta (o de la empresa) se levantan automáticamente en esa fecha (actor system, reason subscription_ended, registrado como cualquier levantamiento, sin periodo de gracia) y las capturas se eliminan con las demás. Para conservar las capturas retenidas, cámbiate a Archive only (o compra el archivo de empresa) antes de esa fecha, o descárgalas. En esos 30 días no se puede aplicar ninguna retención nueva (la cuenta no tiene plan de pago); las existentes aún se pueden levantar. La retención se guarda solo en el registro de la captura, fuera del paquete de pruebas sellado. `reason` es un texto sin formato opcional de como máximo 200 caracteres (sin caracteres de control, invisibles ni de dirección del texto; los espacios se contraen, un valor vacío significa ninguno); se conserva con la retención y se borra al levantarla.\n\nQuién: aplicar una retención, cualquiera que pueda actuar sobre la captura (su autor, en una captura de empresa mientras siga ocupando un puesto de esa empresa, y el propietario de la empresa, también de una empresa cuya suscripción terminó mientras está dentro de su periodo de conservación o se conserva como archivo de empresa; el archivo solo a través de la sesión del panel). Levantarla: en una captura personal, su autor; en una captura de empresa, solo el propietario de la empresa (un puesto recibe 403 LEGAL_HOLD_OWNER_ONLY). Aplicar una retención requiere un plan de pago (cualquier plan de pago, Archive only incluido): el sandbox gratuito, y también una cuenta cuya suscripción ha terminado, recibe 403 LEGAL_HOLD_NOT_AVAILABLE con details.reason \"plan\" (un archivo de empresa en curso cuenta como de pago para las capturas de esa empresa, a través de la sesión del panel de su propietario), y una captura en modo de prueba (con marca de agua, realizada con una clave de prueba o sin facturación vinculada) recibe 403 LEGAL_HOLD_NOT_AVAILABLE con details.reason \"test_capture\". Levantar una retención funciona en todos los planes, también después de que termine la suscripción. En Website Archive la clave de API es de solo lectura (403 PLAN_API_READ_ONLY); la sesión del panel sí puede. Una suscripción vencida o impagada no lo impide. Una sesión del panel o una clave cuya dirección de correo electrónico no está confirmada recibe 403 EMAIL_NOT_VERIFIED.\n\nSe rechaza en una captura que está en la papelera (409 CAPTURE_IN_TRASH: restáurala primero) o que aún se está procesando (409 CAPTURE_PROCESSING); una captura eliminada definitivamente, o cuya eliminación definitiva está en curso, devuelve 404. Cada solicitud de retención legal cuesta una unidad del límite de frecuencia por minuto de la cuenta (429 RATE_LIMITED con Retry-After cuando se agota). Enviar el estado que la captura ya tiene no cambia nada y devuelve 200 con los campos guardados y `changed: false`. Cada cambio se anota en el registro de auditoría como capture.legal_hold (el de una captura de empresa, en el tenant de la empresa), en el historial de accesos de la captura (`legal_hold_on` / `legal_hold_off`, que se conserva aunque la captura se borre más tarde) y, cuando la captura pertenece a un asunto, se escribe en el registro de custodia del asunto como capture.legal_hold_placed o capture.legal_hold_released con el id de la captura como destino (el motivo va al registro de accesos del asunto, nunca a la cadena con hash; la cadena de un asunto sellado no cambia). La respuesta es { capture_id, legal_hold, legal_hold_set_at, legal_hold_reason, changed }.\n\nRetención legal v2: aplicar o levantar una retención también establece o quita un legal hold de S3 Object Lock en cada versión del paquete guardado, en su prueba de OpenTimestamps actualizada y en la copia duplicada guardada por separado (`s3_legal_hold` en GET; cuando el almacenamiento lo rechaza o no tiene configuración de Object Lock, la retención en el registro de la captura se sigue aplicando y el servidor reintenta el intento fallido). Un levantamiento inicia un periodo de gracia de 14 días durante el cual la captura no se elimina definitivamente (no después de que termine la suscripción: lo sustituyen los 30 días posteriores al final). Una retención aplicada por la retención legal de su asunto (`legal_hold_source` \"matter:<id>\") se levanta en el asunto mientras esa retención esté activa: levantarla aquí devuelve 409 LEGAL_HOLD_MATTER (error.details.matter_id para quien pueda leer el asunto). Cada cambio se registra en la cadena de eventos de capturas de la cuenta (GET /captures/events).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "action": {
                    "type": "string",
                    "description": "restore (el valor predeterminado para un cuerpo sin private_note) o legal_hold (con enabled y el reason opcional).",
                    "enum": [
                      "restore",
                      "legal_hold"
                    ]
                  },
                  "private_note": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/PrivateNote"
                      }
                    ],
                    "description": "Establece (una cadena) o borra (null o una cadena vacía) la nota privada. No se puede combinar con action."
                  },
                  "enabled": {
                    "type": "boolean",
                    "description": "Con action legal_hold (obligatorio en ese caso): true aplica la retención legal, false la libera."
                  },
                  "reason": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 200,
                    "description": "Con action legal_hold, opcional: por qué se retiene la captura, texto plano de 200 caracteres como máximo. Se almacena con la retención, fuera del paquete de pruebas; se ignora al liberar (en ese caso va solo al registro de auditoría)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Restaurada ({ capture_id, status, restored: true, matter_id?, matter_relinked? }), sin cambios ({ capture_id, status }), nota privada establecida ({ capture_id, status, private_note }) o retención legal establecida ({ capture_id, legal_hold, legal_hold_set_at, legal_hold_reason, changed }).",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "capture_id": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string"
                    },
                    "restored": {
                      "type": "boolean"
                    },
                    "matter_id": {
                      "type": "string",
                      "nullable": true
                    },
                    "matter_relinked": {
                      "type": "boolean"
                    },
                    "private_note": {
                      "type": "string",
                      "nullable": true
                    },
                    "legal_hold": {
                      "type": "boolean",
                      "description": "Acción de retención legal: la retención de la captura después de la solicitud."
                    },
                    "legal_hold_set_at": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true,
                      "description": "Acción de retención legal: cuándo se aplicó la retención actual (null cuando no hay ninguna)."
                    },
                    "legal_hold_reason": {
                      "type": "string",
                      "nullable": true,
                      "description": "Acción de retención legal: el motivo guardado con la retención actual, o null."
                    },
                    "changed": {
                      "type": "boolean",
                      "description": "Acción de retención legal: false cuando la captura ya tenía el estado solicitado (no se cambió ni se registró nada)."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST: JSON no válido, un cuerpo que no es un objeto, una acción distinta de restore o legal_hold, private_note combinado con action, o un private_note que no es una cadena ni null, supera los 200 caracteres o contiene caracteres de control, invisibles o de dirección del texto. Para legal_hold: enabled ausente o no booleano, un motivo que no es una cadena, supera los 200 caracteres o no es texto plano, o un campo desconocido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "FORBIDDEN: la captura no es tuya. FORBIDDEN también cuando la captura pertenece a un asunto que no está sellado y quien llama (el propietario de la empresa que actúa sobre la captura de un puesto, no su autor) no puede abrir ese asunto. PLAN_API_READ_ONLY: una clave de API de una cuenta Website Archive (su clave es de solo lectura; usa el panel). Para private_note: FORBIDDEN para cualquiera que no sea el autor de la captura (que, además, debe seguir teniendo permiso para actuar sobre ella). Para legal_hold: FORBIDDEN cuando quien llama no puede actuar sobre la captura; LEGAL_HOLD_OWNER_ONLY cuando un puesto (no el propietario de la empresa) levanta la retención legal de una captura de empresa; LEGAL_HOLD_NOT_AVAILABLE al aplicar una retención sin un plan de pago (details.reason plan) o sobre una captura en modo de prueba (details.reason test_capture); EMAIL_NOT_VERIFIED para una dirección de correo electrónico no confirmada. La regla de asuntos anterior no se aplica a la retención legal.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "CAPTURE_NOT_FOUND, incluida una captura eliminada de forma permanente.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Solo retención legal. CAPTURE_IN_TRASH: la captura está en la papelera; restáurala primero. CAPTURE_PROCESSING: la captura aún se está procesando (error.details.status). LEGAL_HOLD_MATTER: la retención de la captura la aplicó la retención legal de su asunto, que sigue activa; levántala en el asunto. LEGAL_HOLD_SOURCE: la retención de una ocultación o comparación que sigue a una captura a partir de la cual se hizo (en una comparación, cualquiera de las capturas comparadas), que sigue bajo retención legal (aplicada a esa captura o a través de su asunto): el paquete permanece retenido hasta que se levante esa retención (`details.source_capture_id`). Una comparación permanece retenida mientras cualquiera de las capturas comparadas lo esté, y se libera solo cuando ninguna lo está (`legal_hold_source` \"derived:<capture id>\" indica la retenida).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Solo retención legal. RATE_LIMITED: se ha agotado el límite de frecuencia por minuto de la cuenta. `error.details.retry_after_seconds` repite Retry-After.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/captures/{capture_id}/verify": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CaptureId"
        }
      ],
      "post": {
        "tags": [
          "Capturas"
        ],
        "operationId": "verifyCapture",
        "summary": "Verificar un paquete almacenado",
        "description": "Lee el paquete guardado y comprueba la firma RSA de manifest.json y el SHA-256 de cada archivo que enumera. Los paquetes de más de 256 MiB devuelven 413 VERIFY_PACKAGE_TOO_LARGE; verifícalos sin conexión con los scripts incluidos. Cada solicitud cuesta una unidad del límite de frecuencia por minuto de la cuenta (429 RATE_LIMITED). Cuando el paquete lleva manifest.json.tsr, ProofSnap también comprueba el token del sello de tiempo eIDAS (checks.eidas): la firma del token y sus atributos firmados, que contenga el SHA-256 de manifest.json, el extendedKeyUsage del certificado de la TSA, su cadena de certificados hasta la CA raíz fijada Disig QCA4 en la fecha del token (genTime) y el estado del certificado de la TSA según la respuesta OCSP o la CRL registrada en la emisión en eidas_validation.json (verificada por firma, no descargada en tiempo real). Esta comprobación nunca solicita un sello de tiempo nuevo. También indica si manifest.json.ots está presente y si el hash del manifiesto está en el registro de emisión de ProofSnap. El veredicto depende solo de la firma del manifiesto y de los archivos enumerados; un token que no supera su comprobación se indica en checks.eidas y en warnings (EIDAS_TOKEN_FAILED), sin incorporarse al veredicto. Un paquete emitido con un sello cualificado de tiempo electrónico registra eidas_requested: true en su manifest.json firmado; cuando ese paquete ya no lleva manifest.json.tsr, checks.eidas y checks.eidas_disig se indican como missing con la advertencia EIDAS_TOKEN_MISSING (los scripts de verificación incluidos dan FAIL para el mismo paquete). Permitido para el propietario de la captura, los miembros de su asunto y el propietario de la empresa para la que se realizó. Para comprobar los sellos de tiempo de forma independiente, ejecuta los scripts de la carpeta verification/ del paquete.\n\nPaquetes importados (source import): el ZIP guardado se vuelve a comprobar con las comprobaciones de la importación (firma, cada archivo, custodia, cadena forense, sellos de tiempo; la procedencia se busca de nuevo en el registro de emisión de la API para los paquetes de la API y se toma del registro de importación para los paquetes de la extensión). La respuesta es { capture_id, source, type: \"import\", package_kind, verifier: \"package-verify\", verdict (match, modified, invalid), rechecked: true, manifest_sha256, zip_sha256, zip_matches_import, kind, summary, levels, checks, reasons, provenance }. Un paquete de más de 512 MiB, o una comprobación que no termina en unos 20 segundos, devuelve rechecked: false con un motivo (too_large_for_synchronous_check, time_budget, storage_error), verdict: null y stored_verification (el informe registrado en el momento de la importación).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Resultado de la verificación.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VerifyResult"
                },
                "example": {
                  "verdict": "match",
                  "capture_id": "cap_a1B2c3D4e5F6g7H8",
                  "source": "s3",
                  "checks": {
                    "manifest_signature": {
                      "status": "valid",
                      "algorithm": "RSA-4096"
                    },
                    "declared_files": {
                      "status": "all_match"
                    },
                    "bitcoin_ots": {
                      "status": "present"
                    },
                    "eidas_disig": null,
                    "proofsnap_issuance": {
                      "status": "issued_by_proofsnap",
                      "manifest_sha256": "3f2a...",
                      "issued_at": "2026-09-01T10:00:00.000Z",
                      "type": "capture"
                    },
                    "eidas": null
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "FORBIDDEN.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "CAPTURE_NOT_FOUND: desconocida, eliminada o sin paquete almacenado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "VERIFY_PACKAGE_TOO_LARGE: el paquete almacenado supera 256 MiB.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "RATE_LIMITED: se agotó el límite de frecuencia por minuto de la cuenta.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "CONFIGURATION_ERROR: no se pudo leer el paquete almacenado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/captures/{capture_id}/redact": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CaptureId"
        }
      ],
      "post": {
        "tags": [
          "Comparar y ocultar datos"
        ],
        "operationId": "redactCapture",
        "summary": "Crear una copia con datos ocultos",
        "description": "Crea un paquete nuevo a partir de una captura completada sin el contenido seleccionado, lo vuelve a firmar (nueva clave RSA-4096) y lo envía para obtener un anclaje de OpenTimestamps. El original no se modifica. Síncrono: devuelve 201 cuando el paquete está listo. Se contabiliza como una captura.\n\n- Las ocultaciones con recuadro tapan en negro zonas de la captura de pantalla. Una captura de pantalla JPEG en la forma en que la captura la escribe (secuencial, en escala de grises o YCbCr) se oculta sin decodificarla a píxeles: los bloques comprimidos (MCU, de 8 a 32 píxeles de lado) que toca un recuadro se sustituyen por bloques negros y todos los demás bloques se conservan sin cambios, por lo que el resto de la imagen no se vuelve a comprimir (`screenshot_method` `jpeg_dct_blocks`, hasta 150 megapíxeles). La zona tapada es cada recuadro redondeado hacia fuera a bloques enteros (`painted_rects`), por lo que siempre contiene el recuadro. Una captura de pantalla PNG, o un JPEG de otro tipo, se decodifica, se pinta y se vuelve a codificar como screenshot.jpeg; después, los bloques de 8 x 8 que toca un recuadro se tapan en negro en el archivo nuevo (`screenshot_method` `decode_repaint_jpeg`, hasta 60 megapíxeles). Una captura de pantalla que supera su límite o con datos de imagen ilegibles se rechaza con 422 REDACTION_SCREENSHOT_UNREADABLE antes de cobrar nada. Si el encabezado es legible pero los datos de imagen no, el error se detecta después de reservar la unidad mensual, y esa unidad se libera. Las ocultaciones de texto y de metadatos funcionan también sin recuadros. redaction_manifest.json (`screenshot_boxes`) registra el método, `painted_rects`, la cuadrícula de bloques y una nota explicativa para los peritos forenses.\n- Las ocultaciones de texto eliminan las coincidencias de page.html y domtextcontent.txt, de cada valor de texto libre de metadata.json (título de la página y valores meta, registros WHOIS / RDAP, el sujeto TLS, los valores de los encabezados de respuesta HTTP) y de cada valor de texto libre de forensic_log.json y chain_of_custody.json (case_reference, case_id, case_note, tus valores de metadatos, el código inyectado y sus errores, las etiquetas de las ventanas emergentes cerradas, legal_context.note). Los identificadores, hashes, horas y URL de esos registros no se modifican. También se eliminan las apariciones codificadas: referencias de caracteres HTML (con nombre, decimales, hexadecimales, como M&uuml;ller o jan&#64;firma.cz), codificación porcentual (mailto:jan%40firma.cz) y secuencias de escape de JavaScript (@, \\x40), incluidas las codificaciones anidadas hasta dos niveles; el marcador sustituye a los propios caracteres codificados. La comprobación posterior a la ocultación, que busca coincidencias supervivientes, lee los archivos JSON valor por valor y busca cada texto tanto tal como está escrito como decodificado, por lo que falla ante una coincidencia en cualquiera de esas formas. Las ocultaciones de texto también tapan en negro la captura de pantalla donde el text_layout.json de la captura de origen sitúa una aparición (`text_rule_rects`): se pintan las líneas estimadas del bloque de texto que contienen la coincidencia, y el bloque entero solo cuando la estimación es incierta (se cuenta en `screenshot_boxes.from_text_rules.rects_whole_block` de redaction_manifest.json); las apariciones que no sitúa se cuentan en `text_occurrences_not_located`. Las reglas distinguen exactamente mayúsculas y minúsculas salvo que se establezca `case_insensitive`.\n- Las ocultaciones de metadatos eliminan campos de metadata.json. Dos nombres tienen mayor alcance. `ip_dns` (también se aceptan `ip`, `dns`, `resolved_ips`) elimina las direcciones IP y las respuestas DNS del sitio de destino de metadata.json, forensic_log.json y chain_of_custody.json (las direcciones propias de la máquina de captura se mantienen) y también las elimina, solo como direcciones completas, de page.html, domtextcontent.txt y la captura de pantalla como reglas de texto automáticas (registradas solo con el origen ip_dns, nunca con la dirección ni con un hash de ella, y sin contar para el límite de text_redactions). `cookie_names` (también `cookie`, `cookies`, `http_headers.cookie`) elimina los encabezados Cookie y Set-Cookie y cada lista de nombres de cookies de esos registros; los recuentos se conservan. Los valores de las cookies ya se ocultan en todas las capturas.\n\nUn recuadro solo tapa píxeles. El texto bajo un recuadro permanece en page.html, domtextcontent.txt y metadata.json salvo que añadas también una ocultación de texto, y una imagen bajo un recuadro puede seguir estando referenciada o incrustada en page.html (img src o srcset, url() de CSS, un URI data:); una ocultación de texto que coincida con su dirección elimina esa referencia. El paquete con datos ocultos conserva del original solo screenshot.jpeg (o screenshot.png cuando ningún recuadro lo sustituyó), page.html, domtextcontent.txt, metadata.json y forensic_log.json, más los archivos que escribe de nuevo (evidence.pdf, chain_of_custody.json, redaction_manifest.json, los archivos de verificación y los archivos del manifiesto). Todos los demás archivos del original, como images/, image_inventory.json, text_layout.json, page.mhtml, network.har y capture.warc, se excluyen y se enumeran en redaction_manifest.json en dropped_for_leak_protection. Las ocultaciones de texto se aplican también a estos nombres de archivo, porque una copia de imagen se nombra a partir de su URL. chain_of_custody.json conserva los eventos del original sobre ellos, marcados como artifact_removed_in_redaction y sin los hashes de los archivos que el paquete con datos ocultos no contiene. También se elimina un rule_902_certification.pdf del original, porque enumera los archivos y hashes del original; la eliminación se indica en redaction_manifest.json (removed_as_stale) y en chain_of_custody.json. La URL de captura nunca se oculta porque forma parte del manifiesto firmado: una ocultación de texto cuyo texto también figura en la URL de captura o en la URL final se aplica en todos los demás lugares, el texto permanece en esa URL y la regla se indica en `rules_in_capture_url` y en redaction_manifest.json (leakage_check.capture_url). Solo se pueden ocultar datos de capturas de páginas web (una comparación, una certificación o una ocultación se rechazan con 422 REDACTION_SOURCE_UNSUPPORTED).\n\nUna ocultación de una captura de prueba (hecha con una clave de prueba, en el sandbox o por una cuenta sin una cuenta de facturación vinculada) es un paquete de prueba, sea cual sea la clave que la solicite: con marca de agua, `test_mode: true` en manifest.json, una fila de prueba (key_env test) que no se contabiliza y no consume ninguna unidad mensual. include_eidas en una ocultación de este tipo se rechaza con 403 EIDAS_REQUIRES_LIVE_KEY (details.reason).\n\nEl informe de ocultación (evidence.pdf) se genera en `language` (predeterminado: el idioma de la captura de origen), incluidos su cuerpo, su portada y su banner de prueba.\n\nAntes de ocultar nada, se vuelve a calcular el hash de cada archivo que la ocultación lee o conserva (la captura de pantalla, page.html, domtextcontent.txt, metadata.json, forensic_log.json, chain_of_custody.json, text_layout.json) y que el manifiesto de origen enumera con un SHA-256 (redaction_manifest.json derived_from.source_files_rehashed); una discrepancia o un archivo ausente devuelve 422 REDACTION_SOURCE_INTEGRITY_FAILED y no se emite nada, de modo que un origen dañado o alterado nunca se vuelve a sellar con una firma nueva. Los archivos que el paquete con datos ocultos excluye no se leen. Una ocultación síncrona cuyo origen tardó demasiado en leerse se detiene antes de reservar nada, con 500 CAPTURE_TIME_BUDGET_EXCEEDED.\n\nUna ocultación de una captura sujeta a retención legal también queda retenida (legal_hold_source \"derived:<capture id>\"), y lo mismo ocurre con cada ocultación de una captura cuando se aplica una retención sobre ella; liberar la retención de la captura libera esas retenciones y, mientras la captura siga retenida, no se pueden liberar por separado (409 LEGAL_HOLD_SOURCE). El captured_at de una fila de ocultación es la hora de captura de su origen; redacted_at es el momento en que se hizo la ocultación.\n\nLa ocultación falla de forma segura (fail closed): cuando la comprobación posterior a la ocultación sigue encontrando un término oculto en cualquier parte del paquete fuera de la URL de captura y de la URL final, no se emite ningún paquete y la solicitud devuelve 422 REDACTION_LEAK_DETECTED (`error.details` `{ files, rule_indexes, metadata_fields, in_capture_url }`; el término en sí nunca se devuelve). redaction_manifest.json registra `leakage_check.fail_closed: true`.\n\nLos valores se truncan en límites de carácter (un carácter nunca se divide): las cadenas de coincidencia de texto a 2000 caracteres, los motivos a 200, reason_summary a 500, los campos del custodio a 200. Los motivos, reason_summary y los campos del custodio se imprimen en el certificado de ocultación y deben ser texto sin formato: un carácter de control, invisible o de dirección del texto se rechaza con 400.\n\nPresupuesto de tiempo y reintentos: la solicitud responde dentro del límite de 29 segundos de API Gateway. El sellado se ejecuta con un presupuesto de tiempo contado desde la solicitud; si el sello cualificado de tiempo electrónico (include_eidas) no cabe en el presupuesto, no se solicita y el paquete se entrega solo con el anclaje de OpenTimestamps (timestamps.eidas con estado not_obtained, motivo time_budget, sin facturar), y una solicitud de OpenTimestamps que no cabe se omite (chain_of_custody.json registra ots_skipped con el motivo time_budget). Envía una Idempotency-Key para que un reintento (por ejemplo, tras un 504 de una pasarela o un error de red) devuelva la misma ocultación en lugar de crear y facturar una segunda. Un reintento mientras la primera solicitud sigue en curso devuelve 409 IDEMPOTENCY_IN_PROGRESS con el ID de la ocultación en `error.details.capture_id` (consulta GET /captures/{capture_id} con él). Un reintento posterior devuelve 201 con el cuerpo original e `Idempotent-Replay: true`. Una ocultación que falla libera su clave, de modo que un reintento con la misma clave se ejecuta de nuevo.\n\nLímite de frecuencia por cuenta en ventanas de 60 segundos (todas las claves de API de la cuenta y su sesión del panel comparten una misma ventana). Una vez ejecutado el limitador, las respuestas de esta ruta incluyen los encabezados `RateLimit-Policy` y `RateLimit` (basados en el borrador IETF draft-ietf-httpapi-ratelimit-headers), además de los heredados `X-RateLimit-Limit`, `X-RateLimit-Remaining` y `X-RateLimit-Reset`. Una respuesta 429 añade `Retry-After`.\n\nUn paquete de pruebas importado (source import, ID con el prefijo `imp_`) no admite ocultación de datos: 422 IMPORT_NOT_SUPPORTED.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "boxes": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "x": {
                          "type": "number",
                          "minimum": 0
                        },
                        "y": {
                          "type": "number",
                          "minimum": 0
                        },
                        "w": {
                          "type": "number",
                          "minimum": 0
                        },
                        "h": {
                          "type": "number",
                          "minimum": 0
                        },
                        "reason": {
                          "type": "string",
                          "maxLength": 200
                        }
                      },
                      "required": [
                        "x",
                        "y",
                        "w",
                        "h"
                      ]
                    },
                    "maxItems": 200,
                    "description": "Rectángulos en píxeles de la captura de pantalla de la captura de origen (x e y desde su esquina superior izquierda, w y h el ancho y el alto). Cuando los cuatro valores de un recuadro están entre 0 y 1, se interpretan como fracciones del ancho y el alto de la captura de pantalla. Un recuadro que queda totalmente fuera de la captura de pantalla no pinta nada y se enumera en ignored_boxes; un recuadro que sobrepasa un borde se recorta en el borde y se enumera en clipped_boxes. Cuando todos los recuadros quedan fuera y ninguna ocultación de texto o de metadatos cambia nada, la solicitud se rechaza con 400 REDACTION_NO_OPS antes de cobrar nada."
                  },
                  "text_redactions": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "match": {
                          "oneOf": [
                            {
                              "type": "string",
                              "description": "Texto literal.",
                              "minLength": 1
                            },
                            {
                              "type": "object",
                              "properties": {
                                "pattern": {
                                  "type": "string",
                                  "description": "Expresión regular, de 1 a 500 caracteres. Los patrones con cuantificadores anidados como (a+)+ se rechazan con 400.",
                                  "minLength": 1,
                                  "maxLength": 500
                                },
                                "flags": {
                                  "type": "string",
                                  "description": "Cualquiera de g, i, m, s, u, cada uno como máximo una vez. y (sticky) se rechaza con 400: solo coincidiría en una posición y dejaría las demás apariciones.",
                                  "pattern": "^[gimsu]*$"
                                }
                              },
                              "required": [
                                "pattern"
                              ]
                            }
                          ]
                        },
                        "case_insensitive": {
                          "type": "boolean",
                          "default": false,
                          "description": "Busca una cadena literal sin distinguir mayúsculas de minúsculas (para una expresión regular, usa el flag i)."
                        },
                        "reason": {
                          "type": "string",
                          "maxLength": 200,
                          "description": "Texto sin formato, impreso en el certificado de ocultación."
                        }
                      },
                      "required": [
                        "match"
                      ]
                    },
                    "maxItems": 200
                  },
                  "metadata_fields": {
                    "type": "array",
                    "description": "Rutas de campos en metadata.json (un segmento `*` coincide con cada elemento de un array), o los nombres `ip_dns`, `cookie_names` y `authorization` (ver arriba).",
                    "items": {
                      "oneOf": [
                        {
                          "type": "string",
                          "description": "Ruta del campo en metadata.json."
                        },
                        {
                          "type": "object",
                          "properties": {
                            "field": {
                              "type": "string"
                            },
                            "reason": {
                              "type": "string"
                            }
                          },
                          "required": [
                            "field"
                          ]
                        }
                      ]
                    },
                    "maxItems": 100
                  },
                  "reason_summary": {
                    "type": "string",
                    "maxLength": 500,
                    "description": "Texto sin formato, impreso en el certificado de ocultación."
                  },
                  "custodian": {
                    "type": "object",
                    "properties": {
                      "name": {
                        "type": "string"
                      },
                      "title": {
                        "type": "string"
                      }
                    },
                    "description": "Se imprime en el informe de ocultación. name y title siguen la regla de texto sin formato (consulta PlainText)."
                  },
                  "language": {
                    "type": "string",
                    "description": "Idioma de los PDF legibles por humanos. Uno de: en, de, it, fr, es, pt_BR, cs, el, hi, hu, id, ja, ko, nl, pl, ro, ru, sv, tr, vi, zh_CN, zh_TW, ar, he, da, fi, nb, sk, uk. Se toleran mayúsculas, guion y región (se aceptan \"DE\", \"fr-FR\" y \"PT-BR\"); se rechazan \"pt\" y \"zh\" solos. Un valor no admitido devuelve 400 MALFORMED_REQUEST. Opcional: sin él (o vacío), el informe está en el idioma de la captura de origen."
                  },
                  "include_eidas": {
                    "type": "boolean",
                    "description": "Como en CaptureOptions.include_eidas.",
                    "default": false
                  },
                  "webhook_url": {
                    "type": "string",
                    "description": "URL https que recibe redaction.completed. Se envía justo después de la respuesta 201, no antes. La URL debe ser https y no debe apuntar a una dirección privada, de loopback, link-local, de NAT de nivel de operador ni de metadatos de la nube (las mismas reglas que la URL de una captura; si no, 422 SSRF_BLOCKED). Un valor vacío o solo con espacios significa sin webhook. Requiere una clave de API: si se envía desde una sesión del panel se rechaza con 400 WEBHOOK_REQUIRES_API_KEY, porque los webhooks se firman con el secreto de firma del webhook de la clave.",
                    "format": "uri"
                  },
                  "rule_commitment_salt": {
                    "type": "string",
                    "description": "Opcional. Tu propio salt de 32 bytes para los compromisos de las reglas (redaction_manifest.json rule_commitments), como 64 dígitos hexadecimales o en base64 / base64url; se rechaza un salt formado por un único byte repetido (400). Envíalo para tener el salt antes de la llamada: sin él, el servidor genera uno y lo devuelve solo en la respuesta 201, de modo que una respuesta perdida por un tiempo de espera agotado (504 del gateway mientras la ocultación sí terminó) pierde el salt, y una repetición con Idempotency-Key no lo incluye. ProofSnap no lo conserva."
                  }
                },
                "description": "Al menos uno de boxes, text_redactions o metadata_fields."
              },
              "example": {
                "text_redactions": [
                  {
                    "match": "jane.doe@example.com",
                    "reason": "personal data"
                  }
                ],
                "reason_summary": "Personal data of a third party"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Paquete con datos ocultos listo. Una repetición idempotente de una ocultación anterior con la misma Idempotency-Key también devuelve 201, con `Idempotent-Replay: true`: el cuerpo original, con el estado actual y un package_url recién prefirmado (se omite cuando el paquete no se puede prefirmar; léelo con GET /captures/{capture_id}).",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              },
              "Idempotent-Replay": {
                "$ref": "#/components/headers/IdempotentReplay"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "redaction_id": {
                      "type": "string",
                      "example": "red_a1B2c3D4e5F6g7H8"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "completed"
                      ]
                    },
                    "redacted_from": {
                      "type": "string"
                    },
                    "redaction_count": {
                      "type": "integer",
                      "description": "Número de acciones de ocultación aplicadas."
                    },
                    "original_signature_verified": {
                      "type": "boolean",
                      "description": "Si la firma del manifiesto original se verificó antes de la ocultación."
                    },
                    "leakage_warnings": {
                      "type": "integer",
                      "description": "Siempre 0: una ocultación cuya comprobación posterior encuentra un término superviviente se rechaza con 422 REDACTION_LEAK_DETECTED en lugar de emitirse. Se mantiene por compatibilidad."
                    },
                    "package_url": {
                      "type": "string",
                      "format": "uri"
                    },
                    "package_url_expires_at": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "verify_url": {
                      "type": "string",
                      "description": "Enlace al verificador público de ProofSnap.",
                      "format": "uri"
                    },
                    "ignored_boxes": {
                      "type": "array",
                      "items": {
                        "type": "integer",
                        "minimum": 0
                      },
                      "description": "Índices (empezando por cero) en los boxes de la solicitud de los recuadros que quedaban completamente fuera de la captura de pantalla y no ennegrecieron nada. También se registran en redaction_manifest.json (screenshot_boxes)."
                    },
                    "clipped_boxes": {
                      "type": "array",
                      "items": {
                        "type": "integer",
                        "minimum": 0
                      },
                      "description": "Índices (empezando por cero) en los boxes de la solicitud de los recuadros que sobrepasaban el borde de la captura de pantalla y se recortaron en él. También se registran en redaction_manifest.json (screenshot_boxes)."
                    },
                    "screenshot_method": {
                      "type": "string",
                      "nullable": true,
                      "enum": [
                        "jpeg_dct_blocks",
                        "decode_repaint_jpeg",
                        null
                      ],
                      "description": "Cómo se pintaron los recuadros: jpeg_dct_blocks (los bloques comprimidos del JPEG bajo cada recuadro se sustituyen por bloques negros, todos los demás bloques sin cambios) o decode_repaint_jpeg (la captura de pantalla se decodifica, se pinta y se vuelve a codificar). null cuando ningún recuadro pintó nada. También se registra en redaction_manifest.json (screenshot_boxes.method)."
                    },
                    "painted_rects": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "x": {
                            "type": "integer",
                            "minimum": 0
                          },
                          "y": {
                            "type": "integer",
                            "minimum": 0
                          },
                          "w": {
                            "type": "integer",
                            "minimum": 0
                          },
                          "h": {
                            "type": "integer",
                            "minimum": 0
                          }
                        },
                        "required": [
                          "x",
                          "y",
                          "w",
                          "h"
                        ]
                      },
                      "description": "Las áreas realmente ennegrecidas, en píxeles de la captura de pantalla, una por cada recuadro aplicado en el orden de los recuadros de la solicitud, sin los ignorados. Con jpeg_dct_blocks, cada una es el recuadro redondeado hacia fuera a bloques completos, por lo que contiene el recuadro; con decode_repaint_jpeg es el recuadro limitado a la captura de pantalla y redondeado hacia fuera a bloques completos de 8 x 8 del nuevo archivo, que se ennegrecen después de la recodificación para que la compresión no pueda llevar colores vecinos al interior del recuadro. También se registra en redaction_manifest.json (screenshot_boxes.painted_rects)."
                    },
                    "text_rule_rects": {
                      "type": "integer",
                      "minimum": 0,
                      "description": "Zonas distintas de la captura de pantalla tapadas en negro solo por las ocultaciones de texto, sin las zonas que contienen un recuadro (una zona en la que coinciden dos reglas cuenta una vez): en los bloques de texto del text_layout.json del origen cuyo texto coincide con una regla, las líneas estimadas que contienen la coincidencia, o el bloque entero cuando la estimación es incierta. Se enumeran en redaction_manifest.json (screenshot_boxes.from_text_rules)."
                    },
                    "text_occurrences_not_located": {
                      "type": "integer",
                      "minimum": 0,
                      "description": "Apariciones de las ocultaciones de texto en el texto de la página que no se pudieron localizar en la captura de pantalla y no se taparon en negro allí (texto que el text_layout.json de origen no lista, como el texto dentro de una imagen; un bloque demasiado grande para taparlo por un solo término; zonas que superan el límite de una ocultación; una captura de pantalla que no se pudo pintar). Las apariciones en un bloque situado totalmente fuera de la captura de pantalla (allí no hay nada que tapar) no se cuentan aquí; redaction_manifest.json las lista como occurrences_not_on_screenshot. Nunca más que las apariciones en el texto de la página. Revisa la captura de pantalla y añade recuadros. redaction_manifest.json (leakage_check.screenshot_text_advisory) tiene los recuentos por regla."
                    },
                    "text_variants_remaining": {
                      "type": "object",
                      "description": "Orientativo, nunca un error: por cada regla de texto literal, las apariciones que quedan en el domtextcontent.txt y en el texto del page.html de este paquete que la regla no detectó pero que coinciden si se ignoran las mayúsculas y minúsculas y los signos diacríticos (por ejemplo \"boskovické\" para la regla que distingue mayúsculas \"Boskovic\"), y si el término aparece en la URL de captura o en la URL final, que nunca se ocultan. `total` suma, por cada regla, el mayor de los dos recuentos (ambos archivos contienen el mismo texto de la página). Solo recuentos, nunca el texto. Las reglas regex no se cuentan. También en redaction_manifest.json (leakage_check.text_variants_advisory). Presente cuando la solicitud contiene ocultaciones de texto.",
                      "properties": {
                        "total": {
                          "type": "integer",
                          "minimum": 0
                        },
                        "per_rule": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "rule_index": {
                                "type": "integer",
                                "minimum": 0
                              },
                              "in_domtextcontent": {
                                "type": "integer",
                                "minimum": 0
                              },
                              "in_page_html_text": {
                                "type": "integer",
                                "minimum": 0
                              },
                              "in_capture_url": {
                                "type": "boolean"
                              }
                            }
                          }
                        }
                      }
                    },
                    "rules_in_capture_url": {
                      "type": "array",
                      "items": {
                        "type": "integer",
                        "minimum": 0
                      },
                      "description": "Presente cuando la solicitud tiene ocultaciones de texto. Índices (desde cero) de las reglas de texto cuyo texto también aparece en la URL de la captura o en la URL final. Esas URL nunca se modifican: el texto se eliminó en todas las demás partes y allí sigue visible. Vacío cuando ninguna regla coincide con ellas. También en redaction_manifest.json (leakage_check.capture_url, `remains_in_capture_url: true` por regla) y en el informe de ocultación."
                    },
                    "rule_commitment_salt": {
                      "type": "string",
                      "pattern": "^[0-9a-f]{64}$",
                      "description": "Presente cuando la solicitud tiene ocultaciones de texto. La sal de 32 bytes (hex; el rule_commitment_salt de la solicitud, cuando se envió uno) de los rule_commitments de redaction_manifest.json: para cada regla de texto, el SHA-256 de la sal de la regla seguida de los bytes UTF-8 del JSON canónico de la regla {\"match\": <text, or {\"pattern\", \"flags\"}>, \"case_insensitive\": <bool>}, donde la sal de la regla es un HMAC-SHA256 con esta sal como clave sobre el texto UTF-8 \"rule:<rule_index>\" (así se puede demostrar una regla revelando su propia sal, sin las demás). Las reglas no se guardan y la sal solo se devuelve en esta respuesta: no en una repetición con Idempotency-Key, un webhook, el registro de eventos ni GET /captures/{capture_id}, y ProofSnap no la conserva. Guárdala con tu documentación: con ella puedes demostrar qué reglas aplicó la ocultación."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST (details.reason; por ejemplo, un motivo que no es texto plano o el flag de regex y), REDACTION_NO_OPS (no se proporcionó nada, o nada coincidió) o INVALID_URL (webhook_url no https). WEBHOOK_REQUIRES_API_KEY: webhook_url enviado desde una sesión del panel (los webhooks necesitan una clave de API).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "QUOTA_EXCEEDED o PAYMENT_REQUIRED.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "403": {
            "description": "FORBIDDEN: la captura no es tuya. EIDAS_REQUIRES_LIVE_KEY: include_eidas con una clave de prueba, o para una ocultación de una captura de prueba (entonces con details.reason). EMAIL_NOT_VERIFIED: una sesión del panel cuya dirección de correo electrónico no está confirmada. PLAN_ARCHIVE_ONLY: el plan solo conserva el archivo y no crea trabajo nuevo. PLAN_API_READ_ONLY: una clave de API de una cuenta Website Archive (su clave es de solo lectura; usa el panel). PLAN_API_READ_ONLY con details.reason \"session_outside_dashboard\": una sesión del panel de una cuenta Website Archive cuya solicitud no tiene cabecera Origin o tiene una que no es una dirección del panel de ProofSnap (en ese plan, el trabajo nuevo se crea solo en el panel; es una barrera blanda, no un límite de seguridad).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "404": {
            "description": "CAPTURE_NOT_FOUND.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "409": {
            "description": "REDACTION_NOT_READY: la captura de origen no está completada. IDEMPOTENCY_IN_PROGRESS: una solicitud con la misma Idempotency-Key sigue en ejecución (details.capture_id es el ID de la ocultación que debes consultar con GET /captures/{capture_id}). COMPANY_READ_ONLY: el origen pertenece a una empresa conservada como archivo de empresa.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "422": {
            "description": "REDACTION_SCREENSHOT_UNREADABLE: los datos de imagen de la captura de pantalla son ilegibles o la captura de pantalla supera el límite de ocultación por recuadros (150 megapíxeles para un JPEG tal como lo escribe la captura, 60 megapíxeles para un PNG u otro JPEG); las ocultaciones de texto y de metadatos siguen funcionando sin recuadros. REDACTION_LEAK_DETECTED: un término ocultado sobrevivió fuera de la URL de la captura (details { files, rule_indexes, metadata_fields, in_capture_url }; in_capture_url es true cuando el texto de la regla rechazada también está en la URL de la captura o en la URL final, donde nunca se cuenta). REDACTION_SOURCE_UNSUPPORTED: el origen no es una captura de página web (details { source } o { manifest_type }). SSRF_BLOCKED: webhook_url apunta a una dirección bloqueada. REDACTION_SOURCE_INTEGRITY_FAILED: un archivo del paquete de origen no coincide con el SHA-256 indicado en su manifiesto firmado, o falta un archivo indicado (details { mismatched, missing }, solo nombres de archivo); no se ocultó ni se cobró nada. IDEMPOTENCY_KEY_REUSED: la Idempotency-Key ya se usó con un cuerpo de solicitud distinto (o desde otro entorno de claves).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "429": {
            "description": "RATE_LIMITED: demasiadas solicitudes en la ventana actual de 60 segundos. `error.details.retry_after_seconds` repite Retry-After. RateLimit indica r=0 y los segundos que faltan para que se restablezca la ventana. Además RATE_LIMITED, para una sesión del panel de Website Archive: su tope diario de 100 unidades por día UTC, compartido por las capturas individuales y por lotes (una unidad por URL), compare, redact y certify-hash (una unidad por solicitud); una solicitud que no cabe se rechaza entera (details.daily_limit, details.remaining, details.action \"wa_session_captures\"; Retry-After y details.retry_after_seconds dan los segundos que faltan para el siguiente día UTC, y se omiten las cabeceras RateLimit y X-RateLimit-* por minuto). Un rechazo breve con solo details.retry_after_seconds y Retry-After: 1 indica una actualización concurrente del contador: vuelve a intentarlo. Un rechazo que no es culpa del llamante devuelve las unidades; el trabajo aceptado con 202 que falla después conserva su unidad. Es un control flexible, no una barrera de seguridad.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "RATE_LIMITED",
                    "message": "Too many requests. See Retry-After header.",
                    "details": {
                      "retry_after_seconds": 42
                    }
                  },
                  "request_id": "..."
                }
              }
            }
          },
          "500": {
            "description": "INTERNAL_ERROR: error inesperado del servidor; reintenta con retroceso exponencial. CAPTURE_TIME_BUDGET_EXCEEDED: la ocultación síncrona agotó su presupuesto de tiempo y se detuvo antes de emitir ningún paquete; no se cobró nada. Con `details.reason` `source_read`, la lectura del paquete de origen consumió la mayor parte del presupuesto: el paquete es demasiado grande para ocultar datos en este momento, inténtalo de nuevo más tarde.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/EidasOrAuthUnavailable"
          }
        },
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ]
      }
    },
    "/compare": {
      "post": {
        "tags": [
          "Comparar y ocultar datos"
        ],
        "operationId": "compareCaptures",
        "summary": "Comparar dos capturas",
        "description": "Compara dos de tus capturas completadas (texto de la página, imágenes mostradas en la página, metadatos) y genera un paquete de comparación firmado. Devuelve 202; consulta GET /captures/{compare_id}, que después incluye type, compared y diff_summary. Las imágenes se comparan a partir del inventario de imágenes que selló cada captura de origen (image_inventory.json: la dirección de cada imagen mostrada y el SHA-256 de sus bytes), nunca píxel a píxel; los resultados están en image_changes.json y en diff_summary.image_changes. Un origen capturado antes de que las capturas registraran un inventario de imágenes se compara igualmente, sin la comparación de imágenes (diff_summary.image_changes.status not_run). Se contabiliza como una captura. En igualdad de condiciones (2026-10-14): dos capturas renderizadas con un perfil de dispositivo distinto o con una configuración de captura distinta se rechazan con 422 COMPARE_PROFILE_MISMATCH (details.differing, details.before, details.after: solo los campos que difieren, el CSS y el JavaScript inyectados como su SHA-256) salvo que allow_profile_mismatch sea true, y dos capturas de páginas distintas con 422 COMPARE_URL_MISMATCH (details.before y details.after: url y final_url como origen y ruta) salvo que allow_url_mismatch sea true. La comprobación se ejecuta primero sobre las dos filas de captura (antes de leer o reservar nada) y después sobre los metadata.json y forensic_log.json sellados de ambos paquetes dentro de la comprobación de origen descrita más abajo; una discrepancia detectada solo durante la ejecución de la comparación hace fallar la comparación con ese código y devuelve la unidad mensual. El texto se compara línea a línea con un diff de líneas determinista (sin tiempo de espera): diff_summary cuenta líneas (text_unit line). Solo se pueden comparar capturas web (hechas mediante la API o por una programación): una certificación de hash, una comparación o un paquete con datos ocultos se rechazan con 422 COMPARE_SOURCE_UNSUPPORTED (`details.capture_id`, `details.source`) antes de leer ningún paquete y antes de reservar nada. Antes de comparar o reservar nada, se vuelve a calcular el SHA-256 de cada archivo que el manifest.json de cada paquete de origen enumera con un SHA-256; un origen con un archivo alterado o ausente, o un manifiesto que no enumera ningún archivo con un SHA-256, se rechaza con 422 COMPARE_SOURCE_INTEGRITY_FAILED (sin comparación, sin fila, sin unidad mensual). Solo se comparan esos archivos enumerados: un archivo dentro de un paquete de origen que su manifiesto no enumera con un SHA-256 (por ejemplo, un text_layout.json, image_inventory.json, page.html o domtextcontent.txt añadido) se trata como ausente. La comprobación se ejecuta en la solicitud cuando ambos paquetes de origen ocupan 50 MB o menos y termina en unos 10 segundos; en caso contrario, la solicitud se acepta y la ejecución de la comparación repite la comprobación, y un origen que falla hace entonces fallar la comparación (GET /captures/{compare_id} muestra el estado failed con error.code COMPARE_SOURCE_INTEGRITY_FAILED) y devuelve la unidad mensual. Una comparación completada registra esta comprobación en compare.json `sources[].source_files_reverified` y en comparison_method.json `source_verification`. /compare no envía webhooks: se ignora un webhook_url en el cuerpo. Consulta GET /captures/{compare_id}.\n\nUna comparación en la que interviene una captura de prueba (hecha con una clave de prueba, en el sandbox o por una cuenta sin una cuenta de facturación vinculada) es un paquete de prueba, sea cual sea la clave que la solicite: con marca de agua, `test_mode: true` en manifest.json, `test_mode: true` para ese origen en compare.json `sources[]` y una fila de prueba (key_env test) que no se contabiliza y no consume ninguna unidad mensual. include_eidas con un origen de prueba se rechaza con 403 EIDAS_REQUIRES_LIVE_KEY (details.reason).\n\nLímite de frecuencia por cuenta en ventanas de 60 segundos (todas las claves de API de la cuenta y su sesión del panel comparten una misma ventana). Una vez ejecutado el limitador, las respuestas de esta ruta incluyen los encabezados `RateLimit-Policy` y `RateLimit` (basados en el borrador IETF draft-ietf-httpapi-ratelimit-headers), además de los heredados `X-RateLimit-Limit`, `X-RateLimit-Remaining` y `X-RateLimit-Reset`. Una respuesta 429 añade `Retry-After`.\n\nEnvía una Idempotency-Key para que un reintento (por ejemplo, tras un 504) devuelva la primera respuesta en lugar de una segunda ejecución facturada (24 horas, mismas reglas que POST /captures). La clave se reserva antes de la comprobación de origen, por lo que un reintento mientras la primera solicitud sigue en curso devuelve 409 IDEMPOTENCY_IN_PROGRESS; una solicitud rechazada por la comprobación de origen o por la cuota libera su clave, de modo que un reintento con la misma clave se comprueba de nuevo.\n\nUn paquete de pruebas importado (source import, ID con el prefijo `imp_`) no se puede comparar: 422 IMPORT_NOT_SUPPORTED (details.capture_id).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "capture_id_1": {
                    "type": "string",
                    "description": "La captura anterior."
                  },
                  "capture_id_2": {
                    "type": "string",
                    "description": "La captura posterior. Debe ser distinta de capture_id_1."
                  },
                  "language": {
                    "type": "string",
                    "description": "Idioma del PDF de comparación. Solo el código exacto; cualquier otro valor recurre al inglés sin avisar.",
                    "enum": [
                      "en",
                      "de",
                      "it",
                      "fr",
                      "es",
                      "pt_BR",
                      "cs",
                      "el",
                      "hi",
                      "hu",
                      "id",
                      "ja",
                      "ko",
                      "nl",
                      "pl",
                      "ro",
                      "ru",
                      "sv",
                      "tr",
                      "vi",
                      "zh_CN",
                      "zh_TW",
                      "ar",
                      "he",
                      "da",
                      "fi",
                      "nb",
                      "sk",
                      "uk"
                    ]
                  },
                  "include_eidas": {
                    "type": "boolean",
                    "description": "Como en CaptureOptions.include_eidas.",
                    "default": false
                  },
                  "include_rule_902": {
                    "type": "boolean",
                    "description": "Añade una plantilla de certificación sin firmar (rule_902_certification.pdf, consulta CaptureOptions.include_rule_902) para la comparación. No disponible para capturas renderizadas de forma distinta: con allow_profile_mismatch true y una discrepancia de perfil o de configuración, 422 RULE902_NOT_AVAILABLE_FOR_MISMATCH. Desactivado actualmente: la plantilla de certificación está desactivada de forma temporal, por lo que este campo se acepta y se ignora (no se genera ni se incluye en el paquete ningún rule_902_certification.pdf) y la respuesta incluye la advertencia RULE902_DISABLED.",
                    "default": false
                  },
                  "allow_profile_mismatch": {
                    "type": "boolean",
                    "default": false,
                    "description": "Compara dos capturas aunque se hicieran con un perfil de dispositivo distinto (options.viewport mobile o desktop, viewport_width, viewport_height, device_scale_factor; a nivel sellado: tamaño del área visible, factor de escala del dispositivo, emulación móvil, user agent sin números de versión) o con una configuración de captura distinta que cambia cómo se renderiza la página (dark_mode, block_ads, block_resources, inject_css, inject_js por SHA-256, expand_collapsed, dismiss_consent, dismiss_popups, página completa). Sin esta opción, un par así se rechaza con 422 COMPARE_PROFILE_MISMATCH. Con ella, diff_summary.text_verdict e images_verdict son undetermined, changed es null salvo que difiera un campo de metadatos de contenido, e include_rule_902 se rechaza."
                  },
                  "allow_url_mismatch": {
                    "type": "boolean",
                    "default": false,
                    "description": "Compara dos capturas de páginas distintas. Dos capturas se consideran de la misma página cuando una dirección de una (la url solicitada, la url y la final_url que registra su metadata.json) es igual a una de la otra tras la normalización (host en minúsculas, http y https equivalentes, puerto predeterminado, y sin fragmento, barra final ni parámetros de consulta volátiles). Sin esta opción, las páginas distintas se rechazan con 422 COMPARE_URL_MISMATCH."
                  }
                },
                "required": [
                  "capture_id_1",
                  "capture_id_2"
                ]
              },
              "example": {
                "capture_id_1": "cap_a1B2c3D4e5F6g7H8",
                "capture_id_2": "cap_h8G7f6E5d4C3b2A1"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Aceptado.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              },
              "X-Quota-Limit": {
                "$ref": "#/components/headers/QuotaLimit"
              },
              "X-Quota-Remaining": {
                "$ref": "#/components/headers/QuotaRemaining"
              },
              "X-Quota-Used": {
                "$ref": "#/components/headers/QuotaUsed"
              },
              "X-Quota-Cap": {
                "$ref": "#/components/headers/QuotaCap"
              },
              "X-Quota-Org-Limit": {
                "$ref": "#/components/headers/QuotaOrgLimit"
              },
              "X-Quota-Org-Used": {
                "$ref": "#/components/headers/QuotaOrgUsed"
              },
              "X-Sandbox-Limit": {
                "$ref": "#/components/headers/SandboxLimit"
              },
              "X-Sandbox-Remaining": {
                "$ref": "#/components/headers/SandboxRemaining"
              },
              "X-Sandbox-Used": {
                "$ref": "#/components/headers/SandboxUsed"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "compare_id": {
                      "type": "string",
                      "example": "cmp_a1B2c3D4e5F6g7H8"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "processing"
                      ]
                    },
                    "poll_path": {
                      "type": "string"
                    },
                    "message": {
                      "type": "string"
                    },
                    "warnings": {
                      "$ref": "#/components/schemas/Rule902DisabledWarnings"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST: JSON no válido, un ID ausente, el mismo ID dos veces, o allow_profile_mismatch / allow_url_mismatch que no es booleano.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "QUOTA_EXCEEDED o PAYMENT_REQUIRED.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "403": {
            "description": "FORBIDDEN: una captura no es tuya. EIDAS_REQUIRES_LIVE_KEY: include_eidas con una clave de prueba, o con una captura de origen de prueba (entonces con details.reason). EMAIL_NOT_VERIFIED: una sesión del panel cuya dirección de correo electrónico no está confirmada. PLAN_ARCHIVE_ONLY: el plan solo conserva el archivo y no crea trabajo nuevo. PLAN_API_READ_ONLY: una clave de API de una cuenta Website Archive (su clave es de solo lectura; usa el panel). PLAN_API_READ_ONLY con details.reason \"session_outside_dashboard\": una sesión del panel de una cuenta Website Archive cuya solicitud no tiene cabecera Origin o tiene una que no es una dirección del panel de ProofSnap (en ese plan, el trabajo nuevo se crea solo en el panel; es una barrera blanda, no un límite de seguridad).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "404": {
            "description": "CAPTURE_NOT_FOUND.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "409": {
            "description": "COMPARE_NOT_READY: ambas capturas deben estar completadas. COMPANY_READ_ONLY: un origen pertenece a una empresa conservada como archivo de empresa (no se crea nada nuevo a partir de ella).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "422": {
            "description": "COMPARE_SOURCE_INTEGRITY_FAILED: un archivo de una captura de origen no coincide con el SHA-256 que indica su manifiesto firmado, o falta; no se ha comparado ni cobrado nada. `details.capture_id`, `details.mismatched`, `details.missing` identifican el primer origen que falla; `details.reason` está presente cuando el propio manifiesto de origen no se puede usar (por ejemplo \"the source manifest lists no file with a SHA-256\" o \"the source manifest lists no files\"); `details.sources[]` enumera cada origen que falla con los mismos campos. Verifica la captura de origen (POST /captures/{capture_id}/verify). COMPARE_SOURCE_UNSUPPORTED: un origen no es una captura web (una certificación de hash, una comparación o un paquete con datos ocultos); no se ha leído, comparado ni cobrado nada. `details.capture_id` identifica ese origen y `details.source` su tipo cuando la fila lo registra. Compara en su lugar las capturas web originales. COMPARE_PROFILE_MISMATCH: las dos capturas se hicieron con un perfil de dispositivo distinto o con una configuración de captura distinta; `details.differing` enumera los nombres de los campos (a nivel de fila: viewport, viewport_width, viewport_height, device_scale_factor, dark_mode, block_ads, block_resources, inject_css, inject_js, expand_collapsed, dismiss_consent, dismiss_popups, full_page; a nivel sellado: viewport_width, viewport_height, device_scale_factor, is_mobile, user_agent y la misma configuración), `details.before` y `details.after` sus valores. Envía allow_profile_mismatch: true para comparar de todos modos. COMPARE_URL_MISMATCH: las dos capturas son de páginas distintas; `details.before` y `details.after` indican url y final_url como origen y ruta. Envía allow_url_mismatch: true para comparar de todos modos. RULE902_NOT_AVAILABLE_FOR_MISMATCH: include_rule_902 con allow_profile_mismatch para capturas renderizadas de forma distinta; `details.differing`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "429": {
            "description": "RATE_LIMITED: demasiadas solicitudes en la ventana actual de 60 segundos. `error.details.retry_after_seconds` repite Retry-After. RateLimit indica r=0 y los segundos que faltan para que se restablezca la ventana. Además RATE_LIMITED, para una sesión del panel de Website Archive: su tope diario de 100 unidades por día UTC, compartido por las capturas individuales y por lotes (una unidad por URL), compare, redact y certify-hash (una unidad por solicitud); una solicitud que no cabe se rechaza entera (details.daily_limit, details.remaining, details.action \"wa_session_captures\"; Retry-After y details.retry_after_seconds dan los segundos que faltan para el siguiente día UTC, y se omiten las cabeceras RateLimit y X-RateLimit-* por minuto). Un rechazo breve con solo details.retry_after_seconds y Retry-After: 1 indica una actualización concurrente del contador: vuelve a intentarlo. Un rechazo que no es culpa del llamante devuelve las unidades; el trabajo aceptado con 202 que falla después conserva su unidad. Es un control flexible, no una barrera de seguridad.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "RATE_LIMITED",
                    "message": "Too many requests. See Retry-After header.",
                    "details": {
                      "retry_after_seconds": 42
                    }
                  },
                  "request_id": "..."
                }
              }
            }
          },
          "500": {
            "description": "INTERNAL_ERROR.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/EidasOrAuthUnavailable"
          }
        },
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ]
      }
    },
    "/certify-hash": {
      "post": {
        "tags": [
          "Certificación de hash"
        ],
        "operationId": "certifyHash",
        "summary": "Certificar hashes de archivos",
        "description": "Envía un SHA-256 (hash) o hasta 100 (hashes). El paquete contiene manifest.json (con tus hashes), manifest.sig, publickey.pem, evidence.pdf, los scripts de verification/ y, cuando está disponible, manifest.json.ots.\n\n- Un hash: síncrono, devuelve 200 con el certificado terminado.\n- Varios hashes con combined: true: un único certificado que enumera todos los hashes, síncrono, devuelve 200.\n- Varios hashes sin combined: un certificado independiente por hash, iniciado en segundo plano; devuelve 202 con los certify_ids. Consulta GET /captures/{certify_id}; con webhook_url, cada uno envía certify.completed.\n\nCada certificado cuenta como una captura para la medición, el límite mensual y el límite de frecuencia (N hashes sin combined cuentan N; un N mayor que el límite por ventana devuelve 400 RATE_LIMIT_COST_TOO_HIGH). Un `label` se imprime en el certificado: se recorta a 200 caracteres y debe ser texto sin formato (sin caracteres de control, invisibles ni de dirección del texto; de lo contrario, 400). En un lote, un error sobre un elemento lo identifica en `error.details.index`.\n\nLímite de frecuencia por cuenta en ventanas de 60 segundos (todas las claves de API de la cuenta y su sesión del panel comparten una ventana). Una vez ejecutado el limitador, las respuestas de esta ruta incluyen las cabeceras `RateLimit-Policy` y `RateLimit` (basadas en el borrador IETF draft-ietf-httpapi-ratelimit-headers) más las heredadas `X-RateLimit-Limit`, `X-RateLimit-Remaining` y `X-RateLimit-Reset`. Un 429 añade `Retry-After`.\n\nEnvía una Idempotency-Key para que un reintento (por ejemplo, tras un 504) devuelva la primera respuesta en lugar de una segunda ejecución facturada (24 horas, mismas reglas que POST /captures).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "hash": {
                    "type": "string",
                    "description": "SHA-256 como 64 caracteres hexadecimales (sin distinguir mayúsculas de minúsculas). Usa hash o hashes."
                  },
                  "label": {
                    "type": "string",
                    "description": "Nombre que se muestra para el archivo. Los valores más largos se acortan a 200 caracteres."
                  },
                  "hashes": {
                    "type": "array",
                    "items": {
                      "oneOf": [
                        {
                          "type": "string",
                          "description": "SHA-256 en hexadecimal."
                        },
                        {
                          "type": "object",
                          "properties": {
                            "hash": {
                              "type": "string"
                            },
                            "label": {
                              "type": "string"
                            }
                          },
                          "required": [
                            "hash"
                          ]
                        }
                      ]
                    },
                    "minItems": 1,
                    "maxItems": 100
                  },
                  "combined": {
                    "type": "boolean",
                    "description": "Certifica todos los hashes en un único certificado.",
                    "default": false
                  },
                  "algorithm": {
                    "type": "string",
                    "enum": [
                      "sha256"
                    ],
                    "default": "sha256"
                  },
                  "language": {
                    "type": "string",
                    "description": "Idioma de los PDF legibles por humanos. Uno de: en, de, it, fr, es, pt_BR, cs, el, hi, hu, id, ja, ko, nl, pl, ro, ru, sv, tr, vi, zh_CN, zh_TW, ar, he, da, fi, nb, sk, uk. Se toleran mayúsculas, guion y región (se aceptan \"DE\", \"fr-FR\" y \"PT-BR\"); se rechazan \"pt\" y \"zh\" solos. Un valor no admitido devuelve 400 MALFORMED_REQUEST."
                  },
                  "case_reference": {
                    "type": "string",
                    "description": "Se imprime en el certificado. Sigue la regla de texto sin formato (consulta PlainText); como máximo 200 caracteres.",
                    "maxLength": 200
                  },
                  "include_eidas": {
                    "type": "boolean",
                    "description": "Como en CaptureOptions.include_eidas.",
                    "default": false
                  },
                  "include_rule_902": {
                    "type": "boolean",
                    "description": "Añade una plantilla de certificación sin firmar (rule_902_certification.pdf, consulta CaptureOptions.include_rule_902). Desactivado actualmente: la plantilla de certificación está desactivada de forma temporal, por lo que este campo se acepta y se ignora (no se genera ni se incluye en el paquete ningún rule_902_certification.pdf) y la respuesta incluye la advertencia RULE902_DISABLED.",
                    "default": false
                  },
                  "webhook_url": {
                    "type": "string",
                    "description": "URL https que recibe certify.completed. La URL debe ser https y no debe apuntar a una dirección privada, de loopback, link-local, de NAT de nivel de operador ni de metadatos de la nube (las mismas reglas que la URL de una captura; si no, 422 SSRF_BLOCKED). Un valor vacío o solo con espacios significa sin webhook. Requiere una clave de API: si se envía desde una sesión del panel se rechaza con 400 WEBHOOK_REQUIRES_API_KEY, porque los webhooks se firman con el secreto de firma del webhook de la clave.",
                    "format": "uri"
                  }
                }
              },
              "example": {
                "hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
                "label": "contract.pdf"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Certificado listo (un hash, o combinado).",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CertifyResult"
                }
              }
            }
          },
          "202": {
            "description": "Se han iniciado varios certificados independientes. Un certificado que el límite mensual o la asignación del sandbox rechaza en el momento de ejecutarse falla: su fila (GET /captures/{certify_id}) tiene status failed y error.code QUOTA_EXCEEDED.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "batch_id": {
                      "type": "string"
                    },
                    "count": {
                      "type": "integer"
                    },
                    "certify_ids": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "description": "ID de los certificados que se crearon (incluido uno cuyo inicio falló)."
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "processing"
                      ]
                    },
                    "certificates": {
                      "type": "array",
                      "description": "Una entrada por cada hash enviado, en orden. status failed: no se pudo iniciar ese certificado (su fila, si se creó, se marca como failed); los demás siguen ejecutándose.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "certify_id": {
                            "type": "string",
                            "nullable": true
                          },
                          "hash_sha256": {
                            "type": "string"
                          },
                          "status": {
                            "type": "string",
                            "enum": [
                              "processing",
                              "failed"
                            ]
                          }
                        }
                      }
                    },
                    "failed_count": {
                      "type": "integer",
                      "description": "Cuántas entradas de certificates no pudieron iniciarse. Cuando fallan todas, la respuesta es en su lugar 500 CONFIGURATION_ERROR."
                    },
                    "warnings": {
                      "$ref": "#/components/schemas/Rule902DisabledWarnings"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST (details.reason indica el campo, por ejemplo \"label must be a string\"; details.index indica el elemento de un lote), INVALID_HASH (details.index en un lote) o INVALID_URL (webhook_url no https, o con credenciales). WEBHOOK_REQUIRES_API_KEY: webhook_url enviado desde una sesión del panel (los webhooks necesitan una clave de API). RATE_LIMIT_COST_TOO_HIGH: más hashes (sin combined) de los que permite una ventana del límite de frecuencia; details { max_per_window, window_seconds, requested }.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "PAYMENT_REQUIRED, o QUOTA_EXCEEDED en las rutas síncronas.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "403": {
            "description": "EIDAS_REQUIRES_LIVE_KEY (details.reason \"no_billing_account\" cuando una clave de producción o una sesión funciona como prueba porque no hay ninguna cuenta de facturación vinculada). EMAIL_NOT_VERIFIED: una sesión del panel cuya dirección de correo electrónico no está confirmada, o una clave de API de una cuenta así. PLAN_ARCHIVE_ONLY: el plan solo conserva el archivo y no crea trabajo nuevo. PLAN_API_READ_ONLY: una clave de API de una cuenta Website Archive (su clave es de solo lectura; usa el panel). PLAN_API_READ_ONLY con details.reason \"session_outside_dashboard\": una sesión del panel de una cuenta Website Archive cuya solicitud no tiene encabezado Origin o tiene uno que no es una dirección del panel de ProofSnap (en ese plan, el trabajo nuevo solo se crea en el panel; es una restricción blanda, no una barrera de seguridad).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "422": {
            "description": "SSRF_BLOCKED: webhook_url apunta a una dirección privada, de loopback, link-local o bloqueada por otro motivo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "RATE_LIMITED: demasiadas solicitudes en la ventana actual de 60 segundos. `error.details.retry_after_seconds` repite Retry-After. RateLimit indica r=0 y los segundos que faltan para que se restablezca la ventana. Además RATE_LIMITED, para una sesión del panel de Website Archive: su tope diario de 100 unidades por día UTC, compartido por las capturas individuales y por lotes (una unidad por URL), compare, redact y certify-hash (una unidad por solicitud); una solicitud que no cabe se rechaza entera (details.daily_limit, details.remaining, details.action \"wa_session_captures\"; Retry-After y details.retry_after_seconds dan los segundos que faltan para el siguiente día UTC, y se omiten las cabeceras RateLimit y X-RateLimit-* por minuto). Un rechazo breve con solo details.retry_after_seconds y Retry-After: 1 indica una actualización concurrente del contador: vuelve a intentarlo. Un rechazo que no es culpa del llamante devuelve las unidades; el trabajo aceptado con 202 que falla después conserva su unidad. Es un control flexible, no una barrera de seguridad.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "RATE_LIMITED",
                    "message": "Too many requests. See Retry-After header.",
                    "details": {
                      "retry_after_seconds": 42
                    }
                  },
                  "request_id": "..."
                }
              }
            }
          },
          "500": {
            "description": "CONFIGURATION_ERROR: la certificación falló (details.reason).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          },
          "504": {
            "description": "UPSTREAM_TIMEOUT: la certificación síncrona (un hash, o combined) no terminó en unos 25 s. Aún puede completarse: details.certify_id y details.poll_path indican la fila que debes consultar con GET /captures/{capture_id}.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "RateLimit": {
                "$ref": "#/components/headers/RateLimit"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              }
            }
          }
        },
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ]
      }
    },
    "/webhook-events/{capture_id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CaptureId"
        }
      ],
      "get": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "listWebhookEvents",
        "summary": "Registro de entregas de webhooks",
        "description": "Todos los intentos de entrega de una captura, un certificado o una ocultación, del más antiguo al más reciente.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Registro de entregas.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "capture_id": {
                      "type": "string"
                    },
                    "webhook_url": {
                      "type": "string",
                      "description": "La URL de webhook de la fila (options.webhook_url de una captura, o la URL indicada con un certificado o una ocultación; null cuando no hay ninguna). Para el propietario de la empresa para la que se hizo una captura, al leer el registro de un puesto, solo esquema, host, puerto y ruta (query y fragmento eliminados, ya que pueden llevar un token); esa lectura se guarda en el historial de accesos de la captura (action webhook_events).",
                      "nullable": true
                    },
                    "attempts": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "attempt_number": {
                            "type": "integer"
                          },
                          "attempted_at": {
                            "type": "string",
                            "format": "date-time"
                          },
                          "status_code": {
                            "type": "integer",
                            "nullable": true
                          },
                          "latency_ms": {
                            "type": "integer",
                            "nullable": true
                          },
                          "success": {
                            "type": "boolean"
                          },
                          "error": {
                            "type": "string",
                            "nullable": true,
                            "description": "null cuando se entregó. En otro caso, una clase genérica, nunca el error de red en bruto: connection_failed, timeout, blocked_address (en el momento de la entrega el host se resolvió en una dirección privada o bloqueada por otro motivo), redirect_not_followed (el endpoint respondió con 3xx; las redirecciones nunca se siguen), http_<status> (una respuesta fuera de 2xx), o una frase que explica por qué no se envió el evento (sin clave de API, clave revocada o no encontrada, sin secreto de firma del webhook, fallo al leer el secreto)."
                          },
                          "delivery_id": {
                            "type": "string",
                            "description": "La cabecera webhook-id de esta entrega sin el prefijo `msg_`."
                          }
                        }
                      }
                    },
                    "attempt_count": {
                      "type": "integer"
                    },
                    "last_success": {
                      "type": "string",
                      "description": "attempted_at del último intento correcto.",
                      "nullable": true
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "FORBIDDEN: la captura no es tuya.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "CAPTURE_NOT_FOUND.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/scheduled-captures": {
      "post": {
        "tags": [
          "Capturas automáticas"
        ],
        "operationId": "createSchedule",
        "summary": "Crear una programación",
        "description": "interval y cron: cada ejecución realiza una captura y, con webhook_url, envía capture.completed o capture.failed.\n\non_change: cada ejecución obtiene primero la página (HTTP simple, o un renderizado en navegador con js_render) y calcula el hash de su texto normalizado. Esa comprobación no es una captura y no se factura. La primera ejecución realiza una captura de referencia. Cuando el hash cambia, se realiza una nueva captura, se compara con la anterior y se envía monitor.change_detected a webhook_url. Una página que devuelve un error o casi ningún texto cuenta como sin cambios.\n\nTras 5 ejecuciones fallidas consecutivas, una programación se pausa sola. Cuando se alcanza el límite mensual o la suscripción tiene pagos vencidos, las ejecuciones se omiten y la programación sigue activa.\n\nLímites por plan:\n\n| Plan | Programaciones activas | Frecuencia mínima | on_change |\n| --- | --- | --- | --- |\n| sandbox | 0 | no disponible | no |\n| developer | 3 | 300 s | sí |\n| startup | 25 | 300 s | sí |\n| growth | 200 | 300 s | sí |\n| custom | ilimitadas | 300 s | sí |\n| legal_solo | 3 | 300 s | sí |\n| legal_seats | 3 | 300 s | sí |\n| website_archive | 30 | 86400 s | sí |\n| archive_only | 0 | no disponible | no |\n\nIdempotency-Key: un reintento con la misma clave y el mismo cuerpo en 24 horas recibe el primer 201 (cabecera Idempotent-Replay: true) en lugar de una segunda programación; la misma clave con otro cuerpo da 422 IDEMPOTENCY_KEY_REUSED.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ScheduleRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Creado.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Schedule"
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST, INVALID_URL, INVALID_CRON, INTERVAL_TOO_SHORT (details.min_interval_seconds) o un error de opciones de captura. WEBHOOK_REQUIRES_API_KEY: webhook_url enviado desde una sesión del panel (los webhooks necesitan una clave de API).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "SCHEDULE_LIMIT_REACHED (details.max_active, details.active) o ON_CHANGE_REQUIRES_PLAN. EMAIL_NOT_VERIFIED: una sesión del panel cuya dirección de correo electrónico no está confirmada. PLAN_ARCHIVE_ONLY: el plan solo conserva el archivo y no crea trabajo nuevo. PLAN_API_READ_ONLY: una clave de API de una cuenta Website Archive (su clave es de solo lectura; usa el panel). PLAN_API_READ_ONLY con details.reason \"session_outside_dashboard\": una sesión del panel de una cuenta Website Archive cuya solicitud no tiene cabecera Origin o tiene una que no es una dirección del panel de ProofSnap (en ese plan el trabajo nuevo solo se crea en el panel; es un control flexible, no una barrera de seguridad).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "SSRF_BLOCKED: url o webhook_url apunta a una dirección privada, de loopback, link-local o bloqueada por otro motivo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        },
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ]
      },
      "get": {
        "tags": [
          "Capturas automáticas"
        ],
        "operationId": "listSchedules",
        "summary": "Listar programaciones",
        "description": "Hasta 100 de tus programaciones.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Programaciones.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "schedules": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Schedule"
                      }
                    },
                    "total": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/scheduled-captures/{schedule_id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ScheduleId"
        }
      ],
      "get": {
        "tags": [
          "Capturas automáticas"
        ],
        "operationId": "getSchedule",
        "summary": "Obtener una programación",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "La programación.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Schedule"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "SCHEDULE_NOT_FOUND.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      },
      "patch": {
        "tags": [
          "Capturas automáticas"
        ],
        "operationId": "updateSchedule",
        "summary": "Pausar, reanudar o editar una programación",
        "description": "Envía status para pausar o reanudar, y/o cualquier campo de ScheduleRequest para editar. Un campo de nivel superior que envíes sustituye al guardado, incluido un null explícito (así se vacían webhook_url, timezone y js_render); un campo que omitas conserva su valor guardado; el resultado se vuelve a validar en su conjunto. `options` es la excepción a la edición campo a campo: se sustituye como mapa completo, no se fusiona clave a clave, así que envía todas las opciones que quieras (léelas antes con GET). Una opción que omitas del mapa se elimina. Reanudar comprueba el límite de programaciones activas, pone a cero el recuento de fallos y calcula la siguiente ejecución a partir de ahora.\n\nDesde una sesión del panel no se puede establecer una webhook_url (400 WEBHOOK_REQUIRES_API_KEY); enviar null o una cadena vacía la elimina. Una programación creada desde una sesión del panel nunca tuvo una clave de API con la que firmar, por lo que editarla también elimina cualquier webhook_url guardada antes, y una clave de API tampoco puede añadirle una (400 WEBHOOK_REQUIRES_API_KEY): el programador firma los webhooks de una programación con el secreto de firma del webhook de la clave con la que se creó. Para recibir webhooks, crea la programación con una clave de API.\n\nCambiar url, mode o js_render de una programación on_change (o cambiar una programación a on_change o desde on_change) borra su referencia (el hash de sondeo guardado y la última captura), de modo que la siguiente ejecución realiza una nueva captura de referencia en lugar de comparar el nuevo destino con el anterior. El cuerpo de la solicitud debe ser un objeto JSON (de lo contrario, 400 MALFORMED_REQUEST).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "active",
                      "paused"
                    ]
                  },
                  "url": {
                    "type": "string",
                    "format": "uri"
                  },
                  "mode": {
                    "type": "string",
                    "enum": [
                      "interval",
                      "cron",
                      "on_change"
                    ]
                  },
                  "interval_seconds": {
                    "type": "integer"
                  },
                  "cron_expr": {
                    "type": "string"
                  },
                  "timezone": {
                    "type": "string",
                    "nullable": true,
                    "description": "Zona horaria IANA para cron. null o una cadena vacía vuelven a UTC."
                  },
                  "webhook_url": {
                    "type": "string",
                    "description": "Envía null o una cadena vacía para eliminarla. La URL debe ser https y no debe apuntar a una dirección privada, de loopback, link-local, de NAT de operador o de metadatos de la nube (las mismas reglas que la URL de una captura; de lo contrario, 422 SSRF_BLOCKED). Requiere una clave de API en ambos lados: si se envía desde una sesión del panel, o con una clave de API para una programación que se creó en el panel, se rechaza con 400 WEBHOOK_REQUIRES_API_KEY, porque los webhooks se firman con el secreto de firma del webhook de la clave con la que se creó la programación.",
                    "nullable": true
                  },
                  "js_render": {
                    "type": "boolean",
                    "nullable": true,
                    "description": "null equivale a false."
                  },
                  "options": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/CaptureOptions"
                      }
                    ],
                    "description": "Sustituye las opciones guardadas en su totalidad (no las fusiona clave a clave). Envía el mapa completo; una opción omitida se elimina. null restablece las opciones a sus valores por defecto. options.webhook_url nunca se guarda (consulta webhook_url)."
                  },
                  "max_capture_age_seconds": {
                    "type": "integer",
                    "nullable": true,
                    "minimum": 86400,
                    "maximum": 34560000,
                    "description": "Solo on_change: una captura completa al menos cada N segundos aunque no haya cambios (de 1 a 400 días, más que interval_seconds). null la elimina. Cambiar el modo a interval o cron la descarta."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "La programación actualizada.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Schedule"
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST (incluido el caso de que no haya nada que actualizar), INVALID_URL, INVALID_CRON o INTERVAL_TOO_SHORT. WEBHOOK_REQUIRES_API_KEY: webhook_url enviado desde una sesión del panel, o enviado con una clave de API para una programación creada en el panel (no tiene ninguna clave cuyo secreto de firma del webhook pueda firmar los webhooks).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "SCHEDULE_LIMIT_REACHED o ON_CHANGE_REQUIRES_PLAN. EMAIL_NOT_VERIFIED: una sesión del panel cuya dirección de correo electrónico no está confirmada (al reanudar una programación pausada; pausar y editar siguen disponibles). PLAN_API_READ_ONLY: una clave de API de una cuenta Website Archive, en cualquier PATCH (incluidos pausar y editar). PLAN_API_READ_ONLY con details.reason \"session_outside_dashboard\": una sesión del panel de una cuenta Website Archive cuya solicitud no tiene cabecera Origin o tiene una que no es una dirección del panel de ProofSnap (en ese plan el trabajo nuevo solo se crea en el panel; es un control flexible, no una barrera de seguridad). PLAN_ARCHIVE_ONLY: reanudar una programación en el plan Archive only.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "SCHEDULE_NOT_FOUND.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "SSRF_BLOCKED: url o webhook_url apunta a una dirección privada, de loopback, link-local o bloqueada por otro motivo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      },
      "delete": {
        "tags": [
          "Capturas automáticas"
        ],
        "operationId": "deleteSchedule",
        "summary": "Eliminar una programación",
        "description": "Detiene las ejecuciones futuras. Las capturas ya realizadas se conservan.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Eliminado.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "schedule_id": {
                      "type": "string"
                    },
                    "deleted": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "PLAN_API_READ_ONLY: una clave de API de una cuenta Website Archive (su clave es de solo lectura; usa el panel).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "SCHEDULE_NOT_FOUND.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/scheduled-captures/{schedule_id}/runs": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ScheduleId"
        }
      ],
      "get": {
        "tags": [
          "Capturas automáticas"
        ],
        "operationId": "listScheduleRuns",
        "summary": "Listar las ejecuciones de una programación",
        "description": "Las 50 capturas más recientes realizadas por esta programación (no se listan las comprobaciones on_change que no encontraron cambios).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Ejecuciones.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "schedule_id": {
                      "type": "string"
                    },
                    "runs": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "capture_id": {
                            "type": "string"
                          },
                          "status": {
                            "type": "string"
                          },
                          "created_at": {
                            "type": "string"
                          },
                          "captured_at": {
                            "type": "string",
                            "nullable": true
                          },
                          "file_count": {
                            "type": "integer",
                            "nullable": true
                          }
                        }
                      }
                    },
                    "total": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "SCHEDULE_NOT_FOUND.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/matters": {
      "get": {
        "tags": [
          "Asuntos"
        ],
        "operationId": "listMatters",
        "summary": "Listar asuntos",
        "description": "Hasta 200 asuntos de los que eres miembro. No se listan los asuntos de una empresa que has abandonado.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Asuntos.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "matters": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Matter"
                      }
                    },
                    "total": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "MATTERS_NOT_ENABLED: el plan no es un plan Legal.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      },
      "post": {
        "tags": [
          "Asuntos"
        ],
        "operationId": "createMatter",
        "summary": "Crear un asunto",
        "description": "Pasas a ser su propietario. El registro de cadena de custodia empieza con una entrada matter.created. Los campos de texto se recortan y se acortan a 200 caracteres.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 200,
                    "description": "Texto sin formato: los espacios se contraen y se recortan, como máximo 200 caracteres, sin caracteres de control, de dirección del texto (bidi) ni invisibles (se admiten los caracteres de unión U+200C y U+200D, que necesita la ortografía hindi y persa) (de lo contrario, 400 MALFORMED_REQUEST). Obligatorio. Se muestra en la página pública de verificación del asunto."
                  },
                  "client": {
                    "type": "string",
                    "maxLength": 200,
                    "description": "Texto sin formato: los espacios se contraen y se recortan, como máximo 200 caracteres, sin caracteres de control, de dirección del texto (bidi) ni invisibles (se admiten los caracteres de unión U+200C y U+200D, que necesita la ortografía hindi y persa) (de lo contrario, 400 MALFORMED_REQUEST). Un valor vacío o null significa sin establecer (se guarda como null). Se rechaza un valor que no sea una cadena.",
                    "nullable": true
                  },
                  "case_number": {
                    "type": "string",
                    "maxLength": 200,
                    "description": "Texto sin formato: los espacios se contraen y se recortan, como máximo 200 caracteres, sin caracteres de control, de dirección del texto (bidi) ni invisibles (se admiten los caracteres de unión U+200C y U+200D, que necesita la ortografía hindi y persa) (de lo contrario, 400 MALFORMED_REQUEST). Un valor vacío o null significa sin establecer (se guarda como null). Se rechaza un valor que no sea una cadena. Se muestra en la página pública de verificación del asunto.",
                    "nullable": true
                  },
                  "opposing_party": {
                    "type": "string",
                    "maxLength": 200,
                    "description": "Texto sin formato: los espacios se contraen y se recortan, como máximo 200 caracteres, sin caracteres de control, de dirección del texto (bidi) ni invisibles (se admiten los caracteres de unión U+200C y U+200D, que necesita la ortografía hindi y persa) (de lo contrario, 400 MALFORMED_REQUEST). Un valor vacío o null significa sin establecer (se guarda como null). Se rechaza un valor que no sea una cadena.",
                    "nullable": true
                  },
                  "jurisdiction": {
                    "type": "string",
                    "description": "Texto libre. Texto plano: los espacios en blanco se contraen y se recortan, como máximo 200 caracteres, sin caracteres de control, de dirección del texto (bidi) ni invisibles (se permiten los caracteres de unión U+200C y U+200D, que necesita la ortografía hindi y persa) (en caso contrario, 400 MALFORMED_REQUEST). Vacío o null significa sin definir (se guarda como null). Se rechaza un valor que no sea una cadena. Se muestra en la página pública de verificación del asunto.",
                    "maxLength": 200,
                    "nullable": true
                  }
                },
                "required": [
                  "name"
                ]
              },
              "example": {
                "name": "Smith v. Jones",
                "case_number": "2026-CV-0142"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Creado.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Matter"
                }
              }
            }
          },
          "400": {
            "description": "MISSING_FIELD: name falta o está vacío. MALFORMED_REQUEST: JSON no válido. MALFORMED_REQUEST: un campo de texto que no es una cadena, supera los 200 caracteres o contiene caracteres de control o de dirección del texto.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "PAYMENT_REQUIRED: llamante en producción cuya suscripción (o la de su empresa) está vencida o impagada."
          },
          "403": {
            "description": "MATTERS_NOT_ENABLED: el plan no es un plan Legal. EMAIL_NOT_VERIFIED: una sesión del panel cuya dirección de correo electrónico no está confirmada, o una clave de API de una cuenta así.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/matters/{matter_id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/MatterId"
        }
      ],
      "get": {
        "tags": [
          "Asuntos"
        ],
        "operationId": "getMatter",
        "summary": "Obtener un asunto",
        "description": "El asunto con sus capturas (hasta 500), su registro de cadena de custodia y hasta 100 eventos de acceso. `members` da el email de cada entrada de `member_uids` y `actor_emails` asigna a su email los uid mencionados en el registro de cadena de custodia y en audit_stream de este asunto (actores y el destinatario de `member.added` / `member.removed`, por lo que también los miembros eliminados); un email es null, o el uid falta en el mapa, cuando no se puede leer. Solo quienes pueden abrir el asunto reciben estos campos; `/matter-verify` nunca lleva emails. Quien puede revocar el enlace público para compartir (el propietario del asunto, el propietario de una empresa finalizada o archivada) recibe también `share`: `{ share_url, created_at, expires_at }` del enlace activo (`expires_at` es siempre null: los enlaces funcionan hasta que se revocan), o null cuando no hay ninguno; los demás miembros nunca reciben el campo. Las entradas de audit_stream llevan source_ip y user_agent solo para el propietario del asunto; los miembros reciben las entradas sin estos dos campos.\n\nUn asunto de una empresa que ha finalizado lleva `read_only: true`.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "El asunto.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Matter"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "members": {
                          "type": "array",
                          "description": "Una entrada por cada entrada de `member_uids`, en el mismo orden.",
                          "items": {
                            "type": "object",
                            "properties": {
                              "uid": {
                                "type": "string"
                              },
                              "email": {
                                "type": "string",
                                "format": "email",
                                "nullable": true,
                                "description": "Null cuando no se puede leer la cuenta."
                              }
                            }
                          }
                        },
                        "actor_emails": {
                          "type": "object",
                          "description": "`{ uid: email }` para las personas mencionadas en custody_log y audit_stream de este asunto (excluido el actor del sistema). Un uid cuyo email no se puede leer no aparece.",
                          "additionalProperties": {
                            "type": "string",
                            "format": "email"
                          }
                        },
                        "captures": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/MatterCapture"
                          }
                        },
                        "custody_log": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/CustodyEntry"
                          }
                        },
                        "audit_stream": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/AuditEntry"
                          }
                        },
                        "share": {
                          "type": "object",
                          "nullable": true,
                          "description": "Solo para quien puede revocar el enlace. Null cuando no existe ningún enlace activo.",
                          "properties": {
                            "share_url": {
                              "type": "string",
                              "format": "uri"
                            },
                            "created_at": {
                              "type": "string",
                              "format": "date-time",
                              "nullable": true
                            },
                            "expires_at": {
                              "type": "string",
                              "format": "date-time",
                              "nullable": true,
                              "description": "Siempre null: los enlaces para compartir no caducan."
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "MATTERS_NOT_ENABLED: el plan no es un plan Legal.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "MATTER_NOT_FOUND.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      },
      "patch": {
        "tags": [
          "Asuntos"
        ],
        "operationId": "updateMatter",
        "summary": "Actualizar un asunto",
        "description": "Solo el propietario. Los campos de texto se recortan (espacios en los extremos) y se acortan a 200 caracteres. name es obligatorio: cuando se envía debe ser una cadena no vacía, y un nombre vacío o formado solo por espacios, null o un valor que no sea una cadena se rechaza con 400 y no se modifica nada. Miembros: add_member_email añade a una persona que ya tiene una cuenta de ProofSnap y pertenece a la empresa del asunto; la ausencia de cuenta, una dirección no confirmada y una cuenta ajena a la empresa reciben la misma respuesta 404 NOT_FOUND; remove_member_uid elimina a un miembro (no al propietario). Cuando se envían ambos, solo se aplica add_member_email. Un asunto tiene como máximo 50 miembros (un asunto completo rechaza la solicitud antes de buscar la dirección). Los cambios de miembros se escriben en el registro de custodia. Un cambio de name, client, case_number, opposing_party, jurisdiction o status se escribe en el registro de custodia como matter.updated; su target es una cadena JSON con fields (los nombres de los campos modificados) y, si cambió el estado, el nuevo status. Los valores de texto libre no se escriben en el registro de custodia público y encadenado por hash: los valores antiguos y nuevos van al registro de accesos (audit_stream, entrada matter.updated). En un asunto archivado, un cambio de nombre o add_member_email se rechaza con 409 MATTER_ARCHIVED, salvo que la misma solicitud lo reabra (status \"open\"); remove_member_uid y la reapertura siguen funcionando. Cada invitación cuenta para un límite diario de 50 por propietario que invita (las invitaciones a la empresa desde el panel y add_member_email de los asuntos cuentan conjuntamente, incluidos los rechazos, ya que cada una busca una dirección); al alcanzar el límite, la solicitud devuelve 429 RATE_LIMITED. Un fallo del servidor no cuenta.\n\nRetención legal: `{ \"legal_hold\": true|false }` por sí solo establece la retención legal del asunto (solo el propietario del asunto) y la aplica a sus capturas en la misma solicitud, con los efectos de la retención de una captura (legal hold de S3 Object Lock, entradas de custodia capture.legal_hold_placed / capture.legal_hold_released, la cadena de eventos de capturas). La respuesta es el asunto más `changed` (el indicador cambió), `captures_held` o `captures_released`, `skipped` ([{ capture_id, code }], p. ej. una captura en la papelera) y `truncated` (más de 1000 capturas: envía de nuevo el mismo valor para continuar). Las capturas asignadas al asunto más tarde mientras la retención está activa quedan retenidas al asignarse. Una unidad del límite de frecuencia por solicitud más una por cada 10 capturas modificadas.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "description": "Debe contener al menos un carácter que no sea un espacio en blanco. Omítelo para conservar el nombre actual. Misma regla de texto plano: espacios en blanco contraídos, como máximo 200 caracteres, sin caracteres de control, de dirección del texto ni invisibles (se permiten U+200C y U+200D).",
                    "maxLength": 200
                  },
                  "client": {
                    "type": "string",
                    "nullable": true,
                    "description": "Texto sin formato: los espacios se contraen y se recortan, como máximo 200 caracteres, sin caracteres de control, de dirección del texto (bidi) ni invisibles (se admiten los caracteres de unión U+200C y U+200D, que necesita la ortografía hindi y persa) (de lo contrario, 400 MALFORMED_REQUEST). null o una cadena formada solo por espacios vacía el campo (se guarda como null). Un valor que no sea una cadena se rechaza con 400.",
                    "maxLength": 200
                  },
                  "case_number": {
                    "type": "string",
                    "nullable": true,
                    "description": "Texto sin formato: los espacios se contraen y se recortan, como máximo 200 caracteres, sin caracteres de control, de dirección del texto (bidi) ni invisibles (se admiten los caracteres de unión U+200C y U+200D, que necesita la ortografía hindi y persa) (de lo contrario, 400 MALFORMED_REQUEST). null o una cadena formada solo por espacios vacía el campo (se guarda como null). Un valor que no sea una cadena se rechaza con 400.",
                    "maxLength": 200
                  },
                  "opposing_party": {
                    "type": "string",
                    "nullable": true,
                    "description": "Texto sin formato: los espacios se contraen y se recortan, como máximo 200 caracteres, sin caracteres de control, de dirección del texto (bidi) ni invisibles (se admiten los caracteres de unión U+200C y U+200D, que necesita la ortografía hindi y persa) (de lo contrario, 400 MALFORMED_REQUEST). null o una cadena formada solo por espacios vacía el campo (se guarda como null). Un valor que no sea una cadena se rechaza con 400.",
                    "maxLength": 200
                  },
                  "jurisdiction": {
                    "type": "string",
                    "nullable": true,
                    "description": "Texto sin formato: los espacios se contraen y se recortan, como máximo 200 caracteres, sin caracteres de control, de dirección del texto (bidi) ni invisibles (se admiten los caracteres de unión U+200C y U+200D, que necesita la ortografía hindi y persa) (de lo contrario, 400 MALFORMED_REQUEST). null o una cadena formada solo por espacios vacía el campo (se guarda como null). Un valor que no sea una cadena se rechaza con 400.",
                    "maxLength": 200
                  },
                  "status": {
                    "type": "string",
                    "description": "Usa POST /matters/{matter_id}/seal para sellar.",
                    "enum": [
                      "open",
                      "archived"
                    ]
                  },
                  "add_member_email": {
                    "type": "string",
                    "format": "email"
                  },
                  "remove_member_uid": {
                    "type": "string"
                  },
                  "legal_hold": {
                    "type": "boolean",
                    "description": "Retención legal del asunto, enviada sola (combinada con otro campo: 400). true retiene todas las capturas actuales del asunto y todas las capturas que se le asignen mientras la retención esté activa (legal_hold_source \"matter:<id>\"; una captura ya retenida conserva su propia retención). false levanta solo las retenciones que aplicó el asunto; una retención manual o masiva se mantiene. Solo el propietario del asunto. No la bloquea una suscripción con pagos vencidos. Aplicarla requiere un plan de pago (403 LEGAL_HOLD_NOT_AVAILABLE, details.reason plan) y las capturas en modo de prueba se omiten (código LEGAL_HOLD_NOT_AVAILABLE en `skipped`); false por sí solo se acepta también después de que haya terminado el plan Legal."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "El asunto actualizado.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Matter"
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST: name vacío, solo con espacios en blanco o que no es una cadena; un campo de texto que no es una cadena, supera los 200 caracteres o contiene caracteres de control o de dirección del texto; status no válido; nada que actualizar; ya es miembro; o eliminar al propietario.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "PAYMENT_REQUIRED: llamante en producción cuya suscripción (o la de su empresa) está vencida o impagada."
          },
          "403": {
            "description": "MATTERS_NOT_ENABLED, FORBIDDEN (no eres el propietario), LEGAL_HOLD_NOT_AVAILABLE (legal_hold true sin un plan de pago) o EMAIL_NOT_VERIFIED: una sesión del panel cuya dirección de correo electrónico no está confirmada, o una clave de API de una cuenta así.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "MATTER_NOT_FOUND o NOT_FOUND (ninguna cuenta apta para el correo electrónico: inexistente, no confirmada o ajena a la empresa, con una única respuesta para todos los casos; o remove_member_uid no es miembro).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "MATTER_SEALED o MATTER_ARCHIVED (cambio de nombre o nuevo miembro en un asunto archivado). COMPANY_READ_ONLY: el asunto pertenece a una empresa que ha finalizado o que se conserva como archivo de empresa (quien llama tiene un plan Legal; abrir, exportar y revocar el uso compartido siguen funcionando).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "RATE_LIMITED: se alcanzó el límite diario de 50 invitaciones de miembros por propietario que invita (las invitaciones a la empresa desde el panel y add_member_email de un asunto cuentan conjuntamente; details.daily_limit, details.action \"member_invite\"). Retry-After indica cuándo termina el día UTC.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/matters/{matter_id}/captures": {
      "parameters": [
        {
          "$ref": "#/components/parameters/MatterId"
        }
      ],
      "post": {
        "tags": [
          "Asuntos"
        ],
        "operationId": "assignMatterCaptures",
        "summary": "Añadir o quitar capturas",
        "description": "Cualquier miembro puede llamar a esta ruta, pero solo se mueven las capturas que creó quien llama; las demás se devuelven en skipped, al igual que las capturas que ya están en otro asunto y las que ya están en este asunto (o, con unassign, no están en él). Las entradas que no son ID de captura se ignoran y se procesan como máximo 100. Cada cambio se escribe en el registro de la cadena de custodia. Un asunto archivado no admite capturas nuevas (409 MATTER_ARCHIVED); unassign sigue funcionando.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "capture_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "maxItems": 100
                  },
                  "unassign": {
                    "type": "boolean",
                    "description": "true elimina las capturas del asunto.",
                    "default": false
                  }
                },
                "required": [
                  "capture_ids"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Resultado.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "matter_id": {
                      "type": "string"
                    },
                    "assigned": {
                      "type": "integer",
                      "description": "Número de capturas modificadas."
                    },
                    "skipped": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "unassign": {
                      "type": "boolean"
                    },
                    "legal_holds_placed": {
                      "type": "integer",
                      "description": "Presente cuando la retención legal del asunto está activa: en cuántas de las capturas asignadas aplicó una retención."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST: ningún ID de captura válido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "PAYMENT_REQUIRED: llamante en producción cuya suscripción (o la de su empresa) está vencida o impagada."
          },
          "403": {
            "description": "MATTERS_NOT_ENABLED o EMAIL_NOT_VERIFIED: una sesión del panel cuya dirección de correo electrónico no está confirmada, o una clave de API de una cuenta así.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "MATTER_NOT_FOUND.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "MATTER_SEALED o MATTER_ARCHIVED (añadir a un asunto archivado). COMPANY_READ_ONLY: el asunto pertenece a una empresa que ha finalizado o que se conserva como archivo de empresa (quien llama tiene un plan Legal; abrir, exportar y revocar el uso compartido siguen funcionando).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/matters/{matter_id}/seal": {
      "parameters": [
        {
          "$ref": "#/components/parameters/MatterId"
        }
      ],
      "post": {
        "tags": [
          "Asuntos"
        ],
        "operationId": "sealMatter",
        "summary": "Sellar un asunto",
        "description": "Solo el propietario. Ancla el hash de la última entrada actual del registro de custodia con OpenTimestamps y, cuando el despliegue tiene eIDAS configurado y quien llama está en producción (una clave de API de producción o una sesión del panel con facturación vinculada), con un sello de tiempo eIDAS (el sello de tiempo eIDAS del sello no se factura; si falla, el sellado continúa sin él). Quien llama en modo de prueba (clave de API de prueba o sesión de sandbox) se sella solo con OpenTimestamps y el sello registra eidas_status not_obtained con eidas_reason test_key; la solicitud no admite ninguna opción eIDAS, así que no es un error. A continuación, el asunto pasa a ser de solo lectura: sin ediciones, sin cambios de capturas, y sus capturas no se pueden eliminar. Un asunto que todavía contiene una captura en la papelera no se sella (409 MATTER_HAS_TRASHED_CAPTURES): restaura la captura o quítala primero del asunto.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Sellado.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "matter_id": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "sealed"
                      ]
                    },
                    "seal": {
                      "$ref": "#/components/schemas/MatterSeal"
                    },
                    "custody_head_hash": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST: el registro de custodia del asunto aún no tiene ninguna entrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "PAYMENT_REQUIRED: llamante en producción cuya suscripción (o la de su empresa) está vencida o impagada."
          },
          "403": {
            "description": "MATTERS_NOT_ENABLED: el plan no es un plan Legal. FORBIDDEN: no eres el propietario. EMAIL_NOT_VERIFIED: una sesión del panel cuya dirección de correo electrónico no está confirmada, o una clave de API de una cuenta así.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "MATTER_NOT_FOUND.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "MATTER_SEALED (ya sellado), MATTER_CHANGED (el registro de custodia cambió durante el sellado; reintenta) o MATTER_HAS_TRASHED_CAPTURES (una captura del asunto está en la papelera; restáurala o quítala primero del asunto). COMPANY_READ_ONLY: el asunto pertenece a una empresa que ha finalizado o que se conserva como archivo de empresa (quien llama tiene un plan Legal; abrir, exportar y revocar el uso compartido siguen funcionando).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "TIMESTAMP_SERVICE_UNAVAILABLE (el anclaje falló, no ha cambiado nada; vuelve a intentarlo) o CONFIGURATION_ERROR.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "EIDAS_SEAL_RETRY: el sello de tiempo eIDAS del sello no llegó a tiempo (o todavía se está emitiendo). No se ha sellado nada. Reintenta después de Retry-After segundos: el reintento recibe el sello de tiempo ya emitido para este último elemento de la cadena de custodia, sin un segundo sello. SERVICE_UNAVAILABLE: la pasarela no pudo comprobar la credencial; reintenta con espera progresiva (backoff).",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              },
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "EIDAS_SEAL_RETRY",
                    "message": "The eIDAS timestamp of the seal did not arrive in time. Nothing was sealed. Retry after the Retry-After interval; the retry reuses the timestamp already issued for this custody head.",
                    "details": {
                      "reason": "The eIDAS timestamp did not arrive in time. Nothing was sealed. Retry the seal.",
                      "retry_after_seconds": 15
                    }
                  },
                  "request_id": "..."
                }
              }
            }
          }
        }
      }
    },
    "/matters/{matter_id}/export": {
      "parameters": [
        {
          "$ref": "#/components/parameters/MatterId"
        }
      ],
      "post": {
        "tags": [
          "Asuntos"
        ],
        "operationId": "exportMatter",
        "summary": "Exportar un asunto",
        "description": "Solo el propietario. Genera un único ZIP con custody_report.pdf, custody_log.json, matter.json, README.txt, access_log.json (cuando hay eventos de acceso) y el paquete de pruebas de cada captura. Con la numeración Bates activada (el valor predeterminado), añade copias con sello Bates de cada evidence.pdf en exhibits/ (anexos numerados a partir de la prueba más antigua) y, con formats.loadfile, un archivo de carga Concordance/Opticon. Con formats.pdfa, las copias para presentación también se convierten a PDF/A cuando el conversor está disponible (en una exportación síncrona, solo dentro de un presupuesto de tiempo); solo las copias contadas en pdfa_count están en PDF/A, las demás son PDF que no afirman ser PDF/A, y README.txt menciona PDF/A solo cuando pdfa_count es mayor que 0.\n\nUn asunto con hasta 20 capturas se exporta de forma síncrona (200). Un asunto más grande se exporta en segundo plano: la respuesta es 202 con un job_id; consulta GET /matters/{matter_id}/export?job_id=. Las capturas que no caben en el límite de tamaño (120 MB en modo síncrono; 800 MB y 300 capturas en segundo plano) se enumeran en los informes pero no se incluyen.\n\nFunciona con asuntos abiertos y sellados. Un asunto archivado se rechaza con 409 MATTER_ARCHIVED (reábrelo primero), salvo para quien no puede reabrirlo: el propietario de una empresa finalizada dentro de su periodo de conservación y una cuenta Archive only. Cada exportación se registra como export.generated en el registro de accesos. Cada exportación cuenta para un límite diario de 50 por usuario; una exportación en segundo plano que falla devuelve su unidad.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "bates": {
                    "type": "object",
                    "properties": {
                      "enabled": {
                        "type": "boolean",
                        "default": true
                      },
                      "prefix": {
                        "type": "string",
                        "description": "Solo letras y dígitos, en mayúsculas, hasta 12. Predeterminado: a partir del número de caso o del nombre del asunto."
                      },
                      "start": {
                        "type": "integer",
                        "minimum": 1,
                        "default": 1
                      },
                      "exhibit_start": {
                        "type": "integer",
                        "minimum": 1,
                        "default": 1
                      },
                      "pad": {
                        "type": "integer",
                        "minimum": 4,
                        "maximum": 8,
                        "default": 6
                      }
                    }
                  },
                  "formats": {
                    "type": "object",
                    "properties": {
                      "loadfile": {
                        "type": "boolean",
                        "description": "Incluye el archivo de carga para e-discovery (requiere numeración Bates).",
                        "default": true
                      },
                      "pdfa": {
                        "type": "boolean",
                        "description": "Convierte a PDF/A las copias para presentación con numeración Bates (requiere numeración Bates). En la medida de lo posible: una copia que no se convierte sigue siendo un PDF sin declaración PDF/A; pdfa_count en la respuesta indica cuántas se convirtieron.",
                        "default": true
                      }
                    }
                  },
                  "export_locale": {
                    "type": "string",
                    "description": "Idioma de custody_report.pdf, del índice de anexos y de README.txt: en, de, it, fr, es, pt_BR, cs, el, hi, hu, id, ja, ko, nl, pl, ro, ru, sv, tr, vi, zh_CN, zh_TW, ar, he, da, fi, nb, sk, uk (se acepta un guion en lugar del guion bajo). Los demás valores recurren al inglés."
                  },
                  "declaration": {
                    "type": "object",
                    "description": "Se acepta por compatibilidad. Sus campos de texto (name, title, party, case_caption, executed_location) siguen la regla de texto sin formato (consulta PlainText): un valor con un carácter no permitido se rechaza con 400 MALFORMED_REQUEST. Los valores demasiado largos se acortan (name, title, executed_location a 120, party a 160, case_caption a 200 caracteres)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Exportación lista.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "matter_id": {
                      "type": "string"
                    },
                    "url": {
                      "type": "string",
                      "description": "Enlace de descarga prefirmado, válido 24 horas.",
                      "format": "uri"
                    },
                    "expires_at": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "capture_count": {
                      "type": "integer"
                    },
                    "captures_included": {
                      "type": "integer"
                    },
                    "captures_skipped": {
                      "type": "integer",
                      "description": "Capturas enumeradas en los informes pero no incluidas porque se alcanzó un límite de tamaño."
                    },
                    "custody_events": {
                      "type": "integer"
                    },
                    "exhibits_count": {
                      "type": "integer",
                      "description": "Copias de los anexos con numeración Bates."
                    },
                    "bates_prefix": {
                      "type": "string",
                      "nullable": true
                    },
                    "searchable": {
                      "type": "boolean",
                      "description": "Siempre false: no se añade ninguna capa de texto a las copias para presentación. Se mantiene para que la estructura de la respuesta sea estable."
                    },
                    "pdfa_count": {
                      "type": "integer",
                      "description": "Copias para presentación en exhibits/ que están en PDF/A. 0 significa que ninguna copia está en PDF/A ni afirma estarlo."
                    },
                    "loadfile": {
                      "type": "boolean"
                    },
                    "size_bytes": {
                      "type": "integer"
                    },
                    "generated_at": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          },
          "202": {
            "description": "Exportación iniciada en segundo plano.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "matter_id": {
                      "type": "string"
                    },
                    "async": {
                      "type": "boolean"
                    },
                    "job_id": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "processing"
                      ]
                    },
                    "poll_path": {
                      "type": "string",
                      "description": "Incluye el stage /v1."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST: una opción de exportación no es válida (details.reason).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "PAYMENT_REQUIRED: llamante en producción cuya suscripción (o la de su empresa) está vencida o impagada."
          },
          "403": {
            "description": "MATTERS_NOT_ENABLED: el plan no es un plan Legal. FORBIDDEN: no eres el propietario. EMAIL_NOT_VERIFIED: una sesión del panel cuya dirección de correo electrónico no está confirmada, o una clave de API de una cuenta así.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "MATTER_NOT_FOUND.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "MATTER_ARCHIVED: reabre primero el asunto (PATCH status \"open\").",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "CONFIGURATION_ERROR: no se pudo generar ni iniciar la exportación.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          },
          "504": {
            "description": "UPSTREAM_TIMEOUT: la exportación síncrona no terminó en unos 25 s (details.reason). Vuelve a intentarlo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "get": {
        "tags": [
          "Asuntos"
        ],
        "operationId": "getMatterExport",
        "summary": "Consultar el estado de una exportación en segundo plano",
        "description": "Solo el propietario. Un trabajo que sigue en proceso 30 minutos después de iniciarse se notifica como failed con el error \"export timed out\". Un trabajo termina una sola vez: un trabajo notificado como failed nunca pasa después a ready, y un trabajo ready nunca se notifica como failed.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "job_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Estado del trabajo. Cuando está listo, se rellenan url y los recuentos.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "job_id": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "processing",
                        "ready",
                        "failed"
                      ]
                    },
                    "url": {
                      "type": "string",
                      "nullable": true
                    },
                    "captures_included": {
                      "type": "integer",
                      "nullable": true
                    },
                    "captures_skipped": {
                      "type": "integer",
                      "nullable": true
                    },
                    "custody_events": {
                      "type": "integer",
                      "nullable": true
                    },
                    "size_bytes": {
                      "type": "integer",
                      "nullable": true
                    },
                    "error": {
                      "type": "string",
                      "nullable": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST: falta job_id.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "MATTERS_NOT_ENABLED: el plan no es un plan Legal. FORBIDDEN: no eres el propietario.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "MATTER_NOT_FOUND o NOT_FOUND para un trabajo desconocido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/matters/{matter_id}/share": {
      "parameters": [
        {
          "$ref": "#/components/parameters/MatterId"
        }
      ],
      "post": {
        "tags": [
          "Asuntos"
        ],
        "operationId": "shareMatter",
        "summary": "Crear un enlace público de verificación",
        "description": "Solo el propietario. Devuelve un enlace que cualquiera puede abrir para ver el nombre del asunto, el número de caso, la jurisdicción, el estado, el registro de custodia y el sello (consulta GET /matter-verify). No se comparte ningún contenido de las capturas. Una nueva llamada devuelve el mismo enlace. El enlace funciona hasta que lo revoques.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Enlace para compartir.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "matter_id": {
                      "type": "string"
                    },
                    "share_url": {
                      "type": "string",
                      "format": "uri"
                    },
                    "token": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "PAYMENT_REQUIRED: llamante en producción cuya suscripción (o la de su empresa) está vencida o impagada."
          },
          "403": {
            "description": "MATTERS_NOT_ENABLED: el plan no es un plan Legal. FORBIDDEN: no eres el propietario. EMAIL_NOT_VERIFIED: una sesión del panel cuya dirección de correo electrónico no está confirmada, o una clave de API de una cuenta así.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "MATTER_NOT_FOUND.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "MATTER_ARCHIVED: un asunto archivado no se comparte. COMPANY_READ_ONLY: el asunto pertenece a una empresa que ha finalizado o que se conserva como archivo de empresa (quien llama tiene un plan Legal; abrir, exportar y revocar el uso compartido siguen funcionando).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      },
      "delete": {
        "tags": [
          "Asuntos"
        ],
        "operationId": "revokeMatterShare",
        "summary": "Revocar el enlace público",
        "description": "Solo el propietario. El enlace deja de funcionar. Un POST posterior crea uno nuevo. El propietario de una empresa finalizada (o conservada como archivo de empresa) y el propietario de un asunto en el plan Archive only pueden revocar el enlace sin un plan Legal (crearlo sigue siendo una función de los planes Legal).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Revocado.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "matter_id": {
                      "type": "string"
                    },
                    "revoked": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "PAYMENT_REQUIRED: llamante en producción cuya suscripción (o la de su empresa) está vencida o impagada."
          },
          "403": {
            "description": "MATTERS_NOT_ENABLED, FORBIDDEN (no eres el propietario) o EMAIL_NOT_VERIFIED: una sesión del panel cuya dirección de correo electrónico no está confirmada, o una clave de API de una cuenta así.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "MATTER_NOT_FOUND.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/health": {
      "get": {
        "tags": [
          "Público"
        ],
        "operationId": "health",
        "summary": "Comprobación de estado",
        "description": "Siempre 200 mientras la API responda. Con deep=1 (o true), la respuesta añade dependencies: si cada servicio dependiente está configurado y, para eIDAS, una prueba en vivo del servicio de sellado de tiempo. Solo se prueba eIDAS; los demás informan únicamente de la configuración. status pasa a degraded cuando eIDAS está configurado y su prueba falla. Una prueba que no responde a tiempo se indica en dependencies (probe_timeout, o eidas.error timeout), nunca como un fallo de /health. deep=1 tiene un límite de frecuencia por IP de cliente (10 por minuto): por encima de él, la solicitud devuelve 429 RATE_LIMITED con Retry-After.",
        "security": [],
        "parameters": [
          {
            "name": "deep",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "1",
                "true"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Estado del servicio.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "enum": [
                        "ok",
                        "degraded"
                      ]
                    },
                    "stage": {
                      "type": "string"
                    },
                    "timestamp": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "uptime_ms": {
                      "type": "integer",
                      "description": "Milisegundos desde que se inició la instancia que responde."
                    },
                    "version": {
                      "type": "string"
                    },
                    "dependencies": {
                      "type": "object",
                      "properties": {
                        "eidas": {
                          "type": "object",
                          "properties": {
                            "configured": {
                              "type": "boolean"
                            },
                            "healthy": {
                              "type": "boolean",
                              "description": "null cuando no está configurado.",
                              "nullable": true
                            },
                            "error": {
                              "type": "string",
                              "description": "Código fijo cuando la sonda eIDAS no informó de un estado correcto: timeout (sin respuesta en 15 s), unavailable (el servicio de sellado de tiempo respondió que no está en buen estado), error (no se pudo ejecutar la sonda). Los detalles se registran en el servidor y nunca se devuelven.",
                              "enum": [
                                "timeout",
                                "unavailable",
                                "error"
                              ]
                            }
                          }
                        },
                        "ots": {
                          "type": "object",
                          "properties": {
                            "configured": {
                              "type": "boolean"
                            }
                          }
                        },
                        "c2pa_sign": {
                          "type": "object",
                          "properties": {
                            "configured": {
                              "type": "boolean"
                            }
                          }
                        },
                        "ntp": {
                          "type": "object",
                          "properties": {
                            "configured": {
                              "type": "boolean"
                            }
                          }
                        },
                        "whois": {
                          "type": "object",
                          "properties": {
                            "configured": {
                              "type": "boolean"
                            }
                          }
                        },
                        "error": {
                          "type": "string",
                          "description": "Solo cuando la propia comprobación de dependencias falló: probe_timeout cuando no respondió en 20 s, probe_failed en caso contrario. La respuesta sigue siendo 200."
                        }
                      },
                      "description": "Solo con deep=1."
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/ledger/{hash}": {
      "get": {
        "tags": [
          "Público"
        ],
        "operationId": "ledgerLookup",
        "summary": "Buscar el hash de un manifiesto",
        "description": "Comprueba si ProofSnap registró la emisión de un paquete cuyo manifest.json tiene este SHA-256. Las capturas, certificados, comparaciones y ocultaciones se registran al emitirse, con el hash del manifiesto que se entrega (después del paso del sello cualificado de tiempo); los paquetes emitidos antes de que existiera el registro de emisión no figuran en él. issued: false solo significa que no hay ningún registro.",
        "security": [],
        "parameters": [
          {
            "name": "hash",
            "in": "path",
            "required": true,
            "description": "SHA-256 de manifest.json, 64 caracteres hexadecimales, en mayúsculas o minúsculas, con el prefijo opcional sha256:.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Resultado de la búsqueda.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "manifest_sha256": {
                      "type": "string"
                    },
                    "issued": {
                      "type": "boolean"
                    },
                    "issued_at": {
                      "type": "string",
                      "nullable": true,
                      "format": "date-time",
                      "description": "Ausente cuando issued es false."
                    },
                    "type": {
                      "type": "string",
                      "enum": [
                        "capture",
                        "certify",
                        "compare",
                        "redaction"
                      ],
                      "nullable": true,
                      "description": "Ausente cuando issued es false."
                    },
                    "eidas": {
                      "type": "boolean",
                      "nullable": true,
                      "description": "Si el paquete emitido lleva un sello cualificado de tiempo electrónico eIDAS (manifest.json.tsr). Ausente cuando issued es false; null para registros escritos antes del 2026-10-01."
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "NOT_FOUND: el hash no tiene 64 caracteres hexadecimales.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/matter-verify": {
      "get": {
        "tags": [
          "Público"
        ],
        "operationId": "matterVerify",
        "summary": "Vista pública de un asunto compartido",
        "description": "Datos de solo lectura detrás del enlace compartido de un asunto: detalles del asunto, el registro de custodia (para que se pueda recalcular la cadena de hash) y el sello con su prueba de OpenTimestamps y su token eIDAS. Sin contenido de capturas, enlaces de descarga ni registro de accesos. attested_by.organization es el nombre, establecido por el propietario, de la empresa a la que pertenece el asunto (un asunto de empresa), o null; un asunto personal no nombra ninguna organización. custody_log[].actor_org es la empresa a la que pertenecía el actor cuando se escribió esa entrada (registrada con la entrada, fuera de la carga útil con hash), solo en un asunto de empresa; las entradas escritas antes de que se registrara este dato muestran null. Límite de frecuencia por IP del cliente (30 solicitudes cada 60 segundos por defecto).",
        "security": [],
        "parameters": [
          {
            "name": "token",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z0-9_-]{16,64}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Vista del asunto.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "matter": {
                      "type": "object",
                      "properties": {
                        "name": {
                          "type": "string"
                        },
                        "case_number": {
                          "type": "string",
                          "nullable": true
                        },
                        "jurisdiction": {
                          "type": "string",
                          "nullable": true
                        },
                        "status": {
                          "type": "string"
                        },
                        "sealed": {
                          "type": "boolean"
                        },
                        "attested_by": {
                          "type": "object",
                          "properties": {
                            "organization": {
                              "type": "string",
                              "nullable": true
                            }
                          }
                        },
                        "capture_count": {
                          "type": "integer"
                        },
                        "custody_count": {
                          "type": "integer"
                        },
                        "custody_head_hash": {
                          "type": "string",
                          "nullable": true
                        },
                        "seal": {
                          "type": "object",
                          "properties": {
                            "root_hash": {
                              "type": "string"
                            },
                            "ots_present": {
                              "type": "boolean"
                            },
                            "eidas_present": {
                              "type": "boolean"
                            },
                            "ots_hex": {
                              "type": "string",
                              "nullable": true
                            },
                            "ots_pending": {
                              "type": "boolean"
                            },
                            "tsr_base64": {
                              "type": "string",
                              "nullable": true
                            },
                            "eidas_serial": {
                              "type": "string",
                              "nullable": true
                            },
                            "eidas_issued_at": {
                              "type": "string",
                              "nullable": true
                            },
                            "eidas_ltv": {
                              "type": "boolean"
                            },
                            "sealed_at": {
                              "type": "string"
                            },
                            "eidas_status": {
                              "type": "string",
                              "enum": [
                                "confirmed",
                                "not_obtained",
                                "not_configured"
                              ],
                              "nullable": true,
                              "description": "null en sellos creados antes del 2026-10-01 sin token eIDAS."
                            },
                            "eidas_reason": {
                              "type": "string",
                              "enum": [
                                "token_refused",
                                "unavailable",
                                "test_key",
                                "account_limit"
                              ],
                              "description": "Solo cuando eidas_status es not_obtained. test_key: el sello lo creó una llamada de prueba, por lo que no se solicitó ningún sello de tiempo eIDAS. account_limit: la cuenta aún no tiene ninguna factura pagada y alcanzó su límite eIDAS (20 por día UTC, 50 por mes UTC), por lo que no se solicitó ningún sello de tiempo eIDAS."
                            },
                            "eidas_verification_status": {
                              "type": "string",
                              "nullable": true,
                              "description": "verified cuando el token superó la comprobación de ProofSnap en el sellado."
                            },
                            "eidas_revocation_status": {
                              "type": "string",
                              "nullable": true,
                              "enum": [
                                "good",
                                "revoked_after_gen_time",
                                "unknown",
                                "not_checked",
                                null
                              ],
                              "description": "Estado del certificado de la TSA establecido en el sellado (no se obtiene en tiempo real)."
                            },
                            "eidas_validation": {
                              "type": "object",
                              "nullable": true,
                              "additionalProperties": true,
                              "description": "El contenido de eidas_validation.json registrado en el sellado (cadena de la TSA, raíz fijada, respuesta OCSP, CRL, registro de verificación) para que el token de tsr_base64 se pueda comprobar offline. null en sellos más antiguos."
                            }
                          },
                          "nullable": true
                        },
                        "created_at": {
                          "type": "string"
                        }
                      }
                    },
                    "custody_log": {
                      "type": "array",
                      "items": {
                        "allOf": [
                          {
                            "$ref": "#/components/schemas/CustodyEntry"
                          },
                          {
                            "type": "object",
                            "properties": {
                              "actor_org": {
                                "type": "string",
                                "nullable": true
                              }
                            }
                          }
                        ]
                      }
                    },
                    "issuer": {
                      "type": "string",
                      "enum": [
                        "ProofSnap"
                      ]
                    },
                    "verified_at": {
                      "type": "string",
                      "nullable": true,
                      "description": "Siempre null."
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "NOT_FOUND: token no válido, desconocido o revocado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/captures/export-jobs": {
      "post": {
        "tags": [
          "Capturas"
        ],
        "operationId": "startCaptureExportJob",
        "summary": "Exportar todas las capturas en una sola descarga (trabajo asíncrono)",
        "description": "Inicia un trabajo que reúne todas tus capturas en una sola descarga, dividida en partes de 2 GB como máximo aproximadamente. Cada parte es un ZIP normal: README.txt (en inglés) más README.<language>.txt (el mismo texto traducido, cuando language no es en; ambos en UTF-8 con marca de orden de bytes y finales de línea CRLF; la línea 1 de cada uno remite al otro), captures.csv (UTF-8 con marca de orden de bytes, CRLF; cada captura de la exportación con las columnas member (el email del autor en una exportación de empresa, vacío en tu propia exportación), capture_id, created_at (cuándo se recibió la solicitud), captured_at (el momento de la captura; vacío para una captura fallida), url, status (completed, failed o unavailable), export_part, file, ots_file (captures/<capture_id>.upgraded.ots cuando esa parte contiene la prueba, si no vacío), test_mode (true para una captura hecha con una clave de prueba: con marca de agua, no es una prueba válida) y note (por qué no se incluye un paquete, también para cada fila unavailable); la misma lista en cada parte, y cuando las partes discrepan, la lista de la parte más alta es la definitiva) y captures/<capture_id>.zip, el paquete de pruebas sellado original byte a byte, más captures/<capture_id>.upgraded.ots cuando hay almacenada una prueba de OpenTimestamps actualizada. Cada paquete interno se verifica por sí solo con su verification/verify.sh o verify.ps1. Un paquete de más de 2 GB no se incluye (aparece como unavailable con una nota; descárgalo por separado con GET /captures/{capture_id}). Las capturas fallidas solo aparecen en captures.csv; las capturas en la papelera y las borradas no se exportan. Las entradas de cada parte llevan como fecha la hora de la exportación (UTC, también como campo de marca de tiempo extendida). Consulta GET /captures/export-jobs/{job_id} hasta que status sea ready. **Reutilización:** cuando tu última exportación está lista, tiene el mismo alcance (y org) e idioma, le queda al menos un día de su ventana de descarga, tus derechos no han cambiado y ninguna captura se ha añadido, completado, enviado a la papelera, restaurado ni borrado desde que se planificó, la solicitud devuelve 200 con ese trabajo (reused: true): no se reconstruye nada y no se consume ninguna unidad diaria. Una exportación lista más reciente del mismo alcance sustituye a la anterior (el trabajo anterior pasa a expired, expired_reason superseded). Quién puede iniciarla: una clave de API de producción o la sesión del panel, en todos los planes, incluidos sandbox, Archive only y las claves de solo lectura (Website Archive). Una clave de prueba recibe 403 FORBIDDEN (la exportación contiene capturas de producción), y GET /captures/export-jobs/{job_id} devuelve 404 EXPORT_JOB_NOT_FOUND a una clave de prueba para cualquier trabajo. Tu propia exportación (scope own) contiene tus capturas personales y las que hiciste para tu empresa actual o para una empresa de la que eres propietario, también mediante una clave mientras esa empresa sea un archivo de empresa activo (las capturas de los demás puestos de un archivo solo se alcanzan a través de la sesión del panel). scope company (solo el propietario de la empresa, también para una empresa finalizada dentro de su ventana de descarga; un archivo de empresa activo solo a través de la sesión del panel) exporta las capturas de todos los puestos hechas para la empresa más las tuyas, con el email del autor en la columna member. Un trabajo en ejecución por cuenta (409 EXPORT_JOB_RUNNING lo indica), 5 trabajos iniciados por cuenta y día UTC (un trabajo fallido devuelve su unidad, uno reutilizado no consume ninguna), el límite de frecuencia por minuto de tu plan. Como máximo las 20 000 capturas más recientes por trabajo (truncated).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "scope": {
                    "type": "string",
                    "enum": [
                      "own",
                      "company"
                    ],
                    "default": "own"
                  },
                  "org": {
                    "type": "string",
                    "description": "Con scope company: una de las empresas de las que eres propietario (la actual o la última), cuando eres propietario de ambas."
                  },
                  "language": {
                    "type": "string",
                    "default": "en",
                    "description": "Idioma de la traducción del README: README.<language>.txt (la traducción completa) junto al README.txt en inglés en cada parte; en solo genera README.txt. Uno de: en, de, it, fr, es, pt_BR, cs, el, hi, hu, id, ja, ko, nl, pl, ro, ru, sv, tr, vi, zh_CN, zh_TW, ar, he, da, fi, nb, sk, uk. Se toleran mayúsculas, guion y región (\"PT-BR\" es pt_BR); \"pt\" y \"zh\" solos y cualquier otro valor dan 400 MALFORMED_REQUEST. Una exportación lista solo se reutiliza para el mismo idioma. El panel envía el idioma de su interfaz."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Tu exportación lista sigue vigente y se reutiliza: ningún trabajo nuevo, ninguna unidad diaria. Lee sus enlaces con GET poll_path.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "job_id",
                    "status",
                    "scope",
                    "poll_path",
                    "reused"
                  ],
                  "properties": {
                    "job_id": {
                      "type": "string",
                      "example": "cexp_3Fh8sKq0aZr2LmN1"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "ready"
                      ]
                    },
                    "scope": {
                      "type": "string",
                      "enum": [
                        "own",
                        "company"
                      ]
                    },
                    "poll_path": {
                      "type": "string",
                      "example": "/v1/captures/export-jobs/cexp_3Fh8sKq0aZr2LmN1"
                    },
                    "reused": {
                      "type": "boolean",
                      "enum": [
                        true
                      ]
                    }
                  }
                }
              }
            }
          },
          "202": {
            "description": "Se ha iniciado el trabajo.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "job_id",
                    "status",
                    "scope",
                    "poll_path"
                  ],
                  "properties": {
                    "job_id": {
                      "type": "string",
                      "example": "cexp_3Fh8sKq0aZr2LmN1"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "processing"
                      ]
                    },
                    "scope": {
                      "type": "string",
                      "enum": [
                        "own",
                        "company"
                      ]
                    },
                    "poll_path": {
                      "type": "string",
                      "example": "/v1/captures/export-jobs/cexp_3Fh8sKq0aZr2LmN1"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST: el cuerpo no es un objeto JSON, o scope / org no es válido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "FORBIDDEN: la solicitud se hizo con una clave de prueba, o con scope company por alguien que no es propietario de una empresa dentro de su plazo de descarga.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "EXPORT_JOB_RUNNING: un trabajo de la cuenta sigue en proceso; details.job_id y details.poll_path lo identifican.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/captures/export-jobs/{job_id}": {
      "get": {
        "tags": [
          "Capturas"
        ],
        "operationId": "getCaptureExportJob",
        "summary": "Estado y enlaces de descarga de un trabajo de exportación de capturas",
        "description": "El estado y el progreso del trabajo; una vez listo, un enlace prefirmado por parte. Un enlace es válido hasta url_expires_at: como máximo 24 horas, nunca más allá de download_expires_at y nunca más allá de las credenciales que lo firmaron (puede ser menos de una hora); vuelve a consultar después de url_expires_at para obtener enlaces nuevos. download_expires_at es 7 días después de la medianoche UTC anterior al inicio de la primera parte del trabajo (de 6 a 7 días después de que el trabajo quedara listo); después se eliminan las partes. El trabajo pierde su descarga antes de tiempo (status expired, las partes eliminadas, expired_reason) cuando una exportación más reciente del mismo alcance lo ha sustituido, se ha borrado una captura que contiene, se han eliminado los datos de su empresa o tus derechos ya no lo cubren (se comprueba en cada lectura de un trabajo listo). Un trabajo que no informa de ningún progreso durante 30 minutos se cierra como failed en esta llamada y se elimina lo que haya escrito. La primera respuesta con enlaces en una hora se anota en el registro de auditoría (capture.export_job.links_issued). Solo la cuenta que inició el trabajo lo ve; una exportación de empresa que contiene un archivo de empresa, solo a través de la sesión del panel.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "job_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^cexp_[A-Za-z0-9]{16}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "El trabajo.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "job_id",
                    "status",
                    "scope",
                    "parts"
                  ],
                  "properties": {
                    "job_id": {
                      "type": "string",
                      "example": "cexp_3Fh8sKq0aZr2LmN1",
                      "pattern": "^cexp_[A-Za-z0-9]{16}$"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "processing",
                        "ready",
                        "failed",
                        "expired"
                      ],
                      "description": "processing mientras se escriben el plan y las partes; ready con los enlaces de descarga; failed (inicia un trabajo nuevo, la unidad del día se ha devuelto); expired cuando la ventana de descarga ha terminado o se ha retirado la descarga (expired_reason; las partes se eliminan)."
                    },
                    "scope": {
                      "type": "string",
                      "enum": [
                        "own",
                        "company"
                      ]
                    },
                    "language": {
                      "type": "string",
                      "enum": [
                        "en",
                        "de",
                        "it",
                        "fr",
                        "es",
                        "pt_BR",
                        "cs",
                        "el",
                        "hi",
                        "hu",
                        "id",
                        "ja",
                        "ko",
                        "nl",
                        "pl",
                        "ro",
                        "ru",
                        "sv",
                        "tr",
                        "vi",
                        "zh_CN",
                        "zh_TW",
                        "ar",
                        "he",
                        "da",
                        "fi",
                        "nb",
                        "sk",
                        "uk"
                      ],
                      "description": "El idioma del README del trabajo (README.<language>.txt además de README.txt; en solo para README.txt)."
                    },
                    "created_at": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    },
                    "ended_at": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    },
                    "progress": {
                      "type": "object",
                      "nullable": true,
                      "properties": {
                        "captures_total": {
                          "type": "integer",
                          "nullable": true
                        },
                        "captures_done": {
                          "type": "integer"
                        },
                        "parts_total": {
                          "type": "integer",
                          "nullable": true
                        },
                        "parts_done": {
                          "type": "integer"
                        }
                      }
                    },
                    "truncated": {
                      "type": "boolean",
                      "description": "true cuando la cuenta tiene más de 20 000 capturas exportables: el trabajo contiene las 20 000 más recientes."
                    },
                    "counts": {
                      "type": "object",
                      "nullable": true,
                      "description": "Se establece cuando el trabajo está listo.",
                      "properties": {
                        "captures": {
                          "type": "integer",
                          "description": "Filas de captures.csv."
                        },
                        "packaged": {
                          "type": "integer",
                          "description": "Paquetes incluidos en las partes."
                        },
                        "failed": {
                          "type": "integer",
                          "description": "Capturas fallidas (sin paquete, solo listadas en captures.csv)."
                        },
                        "unavailable": {
                          "type": "integer",
                          "description": "Paquetes no incluidos: no se pudieron leer durante la exportación o superan 2 GB (captures.csv indica cuáles)."
                        }
                      }
                    },
                    "parts": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": [
                          "part",
                          "bytes",
                          "count",
                          "download_url"
                        ],
                        "properties": {
                          "part": {
                            "type": "integer",
                            "minimum": 1,
                            "example": 1,
                            "description": "Número de parte, de 1 al número de partes."
                          },
                          "bytes": {
                            "type": "integer",
                            "example": 1834201112,
                            "description": "Tamaño en bytes del ZIP de la parte."
                          },
                          "count": {
                            "type": "integer",
                            "example": 412,
                            "description": "Paquetes de captura de esta parte."
                          },
                          "filename": {
                            "type": "string",
                            "example": "proofsnap-captures-2026-10-07T1430Z-part-1-of-3.zip",
                            "description": "Nombre de archivo con el que se guarda la descarga (Content-Disposition): proofsnap-captures-<fecha y hora UTC de inicio del trabajo>, con -part-N-of-M cuando hay varias partes."
                          },
                          "download_url": {
                            "type": "string",
                            "format": "uri",
                            "nullable": true,
                            "description": "GET prefirmado del ZIP de la parte, válido hasta url_expires_at. null cuando el trabajo ha caducado."
                          }
                        }
                      }
                    },
                    "download_expires_at": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true,
                      "description": "Fin del periodo de descarga: 7 días después de la medianoche UTC anterior al inicio de la primera parte (después, el ciclo de vida del almacenamiento elimina las partes)."
                    },
                    "url_expires_at": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true,
                      "description": "Cuándo dejan de funcionar los valores download_url de esta respuesta: como máximo 24 horas, nunca más allá de download_expires_at ni de las credenciales de firma. Vuelve a consultar después para obtener enlaces nuevos."
                    },
                    "error": {
                      "type": "string",
                      "nullable": true,
                      "example": "export timed out"
                    },
                    "expired_reason": {
                      "type": "string",
                      "nullable": true,
                      "enum": [
                        "window_ended",
                        "superseded",
                        "rights_changed",
                        "capture_erased",
                        "company_deleted",
                        null
                      ],
                      "description": "Se establece cuando status es expired: window_ended (download_expires_at ha pasado), superseded (una exportación más reciente del mismo alcance), rights_changed (tus derechos ya no cubren el trabajo), capture_erased (se ha borrado una captura que contiene), company_deleted (se han eliminado los datos de su empresa)."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "EXPORT_JOB_NOT_FOUND: no existe ese trabajo, es un trabajo de otra cuenta, su registro caducó (8 días después de terminar) o la solicitud se hizo con una clave de prueba.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/webhook-subscriptions": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "listWebhookSubscriptions",
        "summary": "Listar suscripciones de webhook",
        "description": "Las suscripciones de webhook de la cuenta, de la más reciente a la más antigua, activas y desactivadas, sin secretos. Clave de API o sesión del panel; también puede usarla una clave de solo lectura (Website Archive).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Suscripciones.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "subscriptions": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/WebhookSubscription"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      },
      "post": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "createWebhookSubscription",
        "summary": "Suscribirse a los eventos de la cuenta",
        "description": "Suscribe un endpoint https a los eventos de la cuenta: cada evento coincidente de la cuenta se le envía mediante POST, firmado con el secreto propio de la suscripción (que se devuelve una sola vez en esta respuesta). Requiere una dirección de email confirmada (403 EMAIL_NOT_VERIFIED). Se permite aunque una suscripción tenga pagos vencidos (no se factura nada). Límites de suscripciones ACTIVAS por cuenta: 25 en developer, startup, growth, custom, legal_solo y legal_seats, 10 en website_archive, 0 en archive_only y sandbox (409 WEBHOOK_SUBSCRIPTION_LIMIT). La URL sigue las reglas de webhook_url (https, sin credenciales, sin direcciones privadas ni reservadas; se vuelve a comprobar en cada entrega). Se admite Idempotency-Key: un reintento con la misma clave y el mismo cuerpo en 24 horas recibe la primera respuesta, secreto incluido, en lugar de una segunda suscripción. Clave de API o sesión del panel; también puede usarla una clave de solo lectura (Website Archive).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url",
                  "events"
                ],
                "additionalProperties": false,
                "properties": {
                  "url": {
                    "type": "string",
                    "example": "https://hooks.example.com/proofsnap"
                  },
                  "events": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 9,
                    "uniqueItems": true,
                    "items": {
                      "$ref": "#/components/schemas/WebhookEventType"
                    }
                  },
                  "filters": {
                    "$ref": "#/components/schemas/WebhookSubscriptionFilters"
                  },
                  "description": {
                    "type": "string",
                    "maxLength": 200,
                    "description": "Texto sin formato, como máximo 200 caracteres."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Creado. `secret` solo se muestra aquí.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookSubscriptionCreated"
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST (events, filters, description, campo desconocido), MISSING_FIELD (url) o INVALID_URL.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "EMAIL_NOT_VERIFIED.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "WEBHOOK_SUBSCRIPTION_LIMIT, IDEMPOTENCY_IN_PROGRESS.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "SSRF_BLOCKED (la URL apunta a una dirección bloqueada) o IDEMPOTENCY_KEY_REUSED.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/webhook-subscriptions/{subscription_id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/SubscriptionId"
        }
      ],
      "get": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "getWebhookSubscription",
        "summary": "Obtener una suscripción de webhook",
        "description": "Una suscripción con su estado de entrega, sin el secreto. Clave de API o sesión del panel; también puede usarlo una clave de solo lectura (Website Archive).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "La suscripción.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookSubscription"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "WEBHOOK_SUBSCRIPTION_NOT_FOUND: desconocida, eliminada o de otra cuenta.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      },
      "patch": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "updateWebhookSubscription",
        "summary": "Cambiar o reactivar una suscripción de webhook",
        "description": "Cambia los eventos, los filtros (null o {} los borra) o la descripción, o reactiva una suscripción desactivada con status \"active\" (restablece los contadores de fallos; se comprueba frente al límite de suscripciones activas). La URL no se puede cambiar: crea una suscripción nueva. Requiere una dirección de correo electrónico confirmada. Clave de API o sesión del panel; una clave de solo lectura (Website Archive) puede usarla.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "events": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 9,
                    "uniqueItems": true,
                    "items": {
                      "$ref": "#/components/schemas/WebhookEventType"
                    }
                  },
                  "filters": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/WebhookSubscriptionFilters"
                      }
                    ],
                    "nullable": true
                  },
                  "description": {
                    "type": "string",
                    "maxLength": 200,
                    "nullable": true
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "active"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "La suscripción modificada.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookSubscription"
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "EMAIL_NOT_VERIFIED.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "WEBHOOK_SUBSCRIPTION_NOT_FOUND: desconocida, eliminada o de otra cuenta.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "WEBHOOK_SUBSCRIPTION_LIMIT (reactivación por encima del límite).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      },
      "delete": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "deleteWebhookSubscription",
        "summary": "Eliminar una suscripción de webhook",
        "description": "Elimina la suscripción, su secreto y sus entregas en cola. 404 para una suscripción desconocida o ya eliminada (considérala eliminada). Clave de API o sesión del panel; una clave de solo lectura (Website Archive) puede usarla.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "204": {
            "description": "Eliminado.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "WEBHOOK_SUBSCRIPTION_NOT_FOUND: desconocida, eliminada o de otra cuenta.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/events": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "listEvents",
        "summary": "Registro de eventos",
        "description": "Todos los eventos emitidos para la cuenta en los últimos 30 días, los haya recibido o no una suscripción, del más reciente al más antiguo. Una clave de API ve los eventos del entorno en el que opera (una clave de producción, los eventos de producción; una clave de prueba, los eventos de prueba); una sesión del panel ve los eventos de producción. Úsalo para recuperar eventos atrasados o para consultar periódicamente (polling) en lugar de suscribirte. Clave de API o sesión del panel; también puede usarlo una clave de solo lectura (Website Archive).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "type",
            "in": "query",
            "required": false,
            "description": "Solo este tipo de evento.",
            "schema": {
              "$ref": "#/components/schemas/WebhookEventType"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "De 1 a 100, valor predeterminado 25.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            }
          },
          {
            "name": "starting_after",
            "in": "query",
            "required": false,
            "description": "Un ID de evento: la página empieza después de él (pasa next_cursor). Un ID de otra cuenta u otro entorno, o con más de 30 días de antigüedad, devuelve 400.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Una página de eventos.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "events": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Event"
                      }
                    },
                    "has_more": {
                      "type": "boolean"
                    },
                    "next_cursor": {
                      "type": "string",
                      "nullable": true,
                      "description": "Pásalo como starting_after para la página siguiente; null en la última página."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST (type, limit o starting_after).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/whoami": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "whoami",
        "summary": "Quién soy",
        "description": "Qué es la credencial: plan, entorno, si la clave de API es de solo lectura, y el id, el nombre y el prefijo de la clave (null para una sesión del panel). Las integraciones lo usan para probar una conexión y para etiquetarla. Sin dirección de email ni id de cuenta. Clave de API o sesión del panel; también puede usarlo una clave de solo lectura (Website Archive).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "La credencial.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "plan": {
                      "type": "string",
                      "nullable": true,
                      "example": "startup",
                      "enum": [
                        "developer",
                        "startup",
                        "growth",
                        "custom",
                        "legal_solo",
                        "legal_seats",
                        "website_archive",
                        "archive_only",
                        "sandbox",
                        null
                      ]
                    },
                    "api_read_only": {
                      "type": "boolean",
                      "description": "true para una clave de API de una cuenta Website Archive (puede leer y suscribirse, no crear capturas)."
                    },
                    "env": {
                      "type": "string",
                      "enum": [
                        "live",
                        "test"
                      ],
                      "description": "El entorno en el que opera la credencial."
                    },
                    "key": {
                      "type": "object",
                      "nullable": true,
                      "properties": {
                        "key_id": {
                          "type": "string"
                        },
                        "name": {
                          "type": "string",
                          "nullable": true
                        },
                        "prefix": {
                          "type": "string",
                          "enum": [
                            "ps_live_",
                            "ps_test_"
                          ]
                        }
                      }
                    },
                    "email_verified": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/captures/events": {
      "get": {
        "tags": [
          "Capturas"
        ],
        "operationId": "listCaptureEvents",
        "summary": "Cadena de eventos de captura (eliminaciones y retenciones legales)",
        "description": "El registro a prueba de manipulaciones de la cuenta con lo que ha ocurrido con sus capturas: papelera, restauración, eliminación definitiva (erase), vaciado de la papelera, eliminación por retención, eliminación junto con la cuenta o la empresa, retención legal aplicada y liberada (una liberación por el sistema tras finalizar la suscripción tiene actor `system` y detail `subscription_ended`), el resultado de S3 Object Lock y la importación de un paquete de pruebas (type `import`, detail = el tipo de paquete). De más antiguo a más reciente. La cadena sobrevive a las capturas y a los datos de la cuenta: solo contiene ID y hashes de manifiestos, ningún contenido de capturas ni texto libre.\n\nCómo verificarla: (1) para cada evento, calcula el SHA-256 sobre el JSON canónico de los campos con hash (verification.hashed_fields; claves ordenadas, sin espacios, valores ausentes como null) y compáralo con `hash`; (2) comprueba que cada `prev_hash` es igual al `hash` del evento anterior (64 ceros para seq 1) y que seq no tiene huecos; (3) para un anclaje, comprueba que `head_hash` es igual al `hash` del evento `to_seq`, escribe `ots_proof_base64` en un archivo (decodificado de base64) y ejecuta `ots verify -d <head_hash> anchor.ots` (cliente de OpenTimestamps): demuestra que la cadena hasta ese evento existía a más tardar en el bloque de Bitcoin. Los anclajes se crean una vez al día para cada cuenta con eventos de un día anterior.\n\nClave de API o sesión del panel, sobre la propia cuenta; `scope=company` da al propietario de una empresa la cadena de las capturas de la empresa (`org` elige una cuando quien llama es propietario de varias; una empresa conservada como archivo de empresa, solo a través de la sesión del panel). Una clave de prueba recibe 403 FORBIDDEN: la cadena cubre capturas de producción y no se puede filtrar sin romperla.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "De 1 a 500, valor predeterminado 100.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 100
            }
          },
          {
            "name": "starting_after",
            "in": "query",
            "required": false,
            "description": "Un seq de evento: la página empieza después de él (pasa next_starting_after).",
            "schema": {
              "type": "integer",
              "minimum": 0
            }
          },
          {
            "name": "scope",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "own",
                "company"
              ],
              "default": "own"
            }
          },
          {
            "name": "org",
            "in": "query",
            "required": false,
            "description": "scope=company: el id de la empresa.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Una página de la cadena.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "chain": {
                      "type": "string"
                    },
                    "scope": {
                      "type": "string",
                      "enum": [
                        "own",
                        "company"
                      ]
                    },
                    "head": {
                      "type": "object",
                      "properties": {
                        "chain": {
                          "type": "string"
                        },
                        "seq": {
                          "type": "integer"
                        },
                        "head_hash": {
                          "type": "string",
                          "nullable": true
                        },
                        "anchored_seq": {
                          "type": "integer"
                        }
                      }
                    },
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CaptureEvent"
                      }
                    },
                    "has_more": {
                      "type": "boolean"
                    },
                    "next_starting_after": {
                      "type": "integer",
                      "nullable": true
                    },
                    "anchors": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CaptureEventAnchor"
                      }
                    },
                    "verification": {
                      "type": "object",
                      "properties": {
                        "hash": {
                          "type": "string"
                        },
                        "hashed_fields": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        },
                        "genesis_prev_hash": {
                          "type": "string"
                        },
                        "anchor": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST: limit, starting_after o scope no válido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "FORBIDDEN: la solicitud se hizo con una clave de prueba, o con scope=company por alguien que no es propietario de la empresa.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/legal-holds": {
      "post": {
        "tags": [
          "Capturas"
        ],
        "operationId": "bulkLegalHold",
        "summary": "Aplicar o levantar retenciones legales de forma masiva",
        "description": "Aplica (enabled true) o levanta (false) la retención legal de muchas capturas a la vez. selector contiene exactamente uno de: `capture_ids` (de 1 a 500 IDs), `matter_id` (las capturas de un asunto que el llamante puede abrir), `host` (las capturas propias del llamante de ese nombre de host) o `schedule_id` (las capturas propias del llamante realizadas por una de sus programaciones). Cada captura pasa por las reglas de PATCH /captures/{capture_id} con la acción legal_hold y obtiene su propio resultado: applied, unchanged (ya en ese estado) o rechazada con el código de error (p. ej. FORBIDDEN, CAPTURE_IN_TRASH, CAPTURE_PROCESSING, LEGAL_HOLD_OWNER_ONLY, LEGAL_HOLD_MATTER, LEGAL_HOLD_SOURCE para una ocultación cuya captura de origen sigue retenida, CAPTURE_NOT_FOUND, LEGAL_HOLD_NOT_AVAILABLE para una captura en modo de prueba). Las ocultaciones realizadas a partir de cada captura siguen su retención, también en una captura indicada como unchanged; cuando eso no termina dentro de la solicitud, se completa en pocos minutos. Aplicar retenciones requiere un plan de pago: el sandbox gratuito recibe 403 LEGAL_HOLD_NOT_AVAILABLE (details.reason \"plan\") para toda la solicitud; levantarlas funciona en todos los planes. Una retención aplicada aquí tiene legal_hold_source \"bulk\"; levantarla de forma masiva o una a una funciona igual. Como máximo 500 capturas por solicitud: un asunto, host o programación con más se trunca (`truncated: true`, envíalo de nuevo). El legal hold de S3 Object Lock de las primeras 25 capturas modificadas se aplica en la solicitud, el resto en pocos minutos (`s3_legal_hold.status` pending). Coste: una unidad del límite de frecuencia por cada 10 capturas (al menos una); una solicitud que necesite más de lo que permite una ventana devuelve 400 RATE_LIMIT_COST_TOO_HIGH. Clave de API o sesión del panel; en Website Archive la clave de API es de solo lectura (403 PLAN_API_READ_ONLY). Una sesión del panel o una clave cuya dirección de correo electrónico no está confirmada recibe 403 EMAIL_NOT_VERIFIED. Una suscripción vencida no lo bloquea.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "enabled",
                  "selector"
                ],
                "additionalProperties": false,
                "properties": {
                  "enabled": {
                    "type": "boolean"
                  },
                  "reason": {
                    "type": "string",
                    "maxLength": 200,
                    "description": "Texto sin formato que se conserva con cada retención aplicada (no con un levantamiento); nunca en las cadenas con hash."
                  },
                  "selector": {
                    "type": "object",
                    "minProperties": 1,
                    "maxProperties": 1,
                    "properties": {
                      "capture_ids": {
                        "type": "array",
                        "minItems": 1,
                        "maxItems": 500,
                        "items": {
                          "type": "string"
                        }
                      },
                      "matter_id": {
                        "type": "string"
                      },
                      "host": {
                        "type": "string"
                      },
                      "schedule_id": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Resultados por captura.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "enabled": {
                      "type": "boolean"
                    },
                    "selector": {
                      "type": "string",
                      "enum": [
                        "capture_ids",
                        "matter_id",
                        "host",
                        "schedule_id"
                      ]
                    },
                    "requested": {
                      "type": "integer"
                    },
                    "truncated": {
                      "type": "boolean"
                    },
                    "applied": {
                      "type": "integer"
                    },
                    "unchanged": {
                      "type": "integer"
                    },
                    "refused": {
                      "type": "integer"
                    },
                    "results": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "capture_id": {
                            "type": "string"
                          },
                          "result": {
                            "type": "string",
                            "enum": [
                              "applied",
                              "unchanged",
                              "refused"
                            ]
                          },
                          "code": {
                            "type": "string",
                            "description": "El código de error de un rechazo."
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST (cuerpo, selector, más de 500 capture_ids), RATE_LIMIT_COST_TOO_HIGH.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "EMAIL_NOT_VERIFIED, PLAN_API_READ_ONLY, LEGAL_HOLD_NOT_AVAILABLE (aplicación sin un plan de pago, details.reason plan).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "MATTER_NOT_FOUND (matter_id que quien llama no puede abrir), SCHEDULE_NOT_FOUND.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "RATE_LIMITED.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/imports": {
      "post": {
        "tags": [
          "Importación de paquetes"
        ],
        "operationId": "createImport",
        "summary": "Iniciar la importación de un paquete",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "description": "Crea una importación e inicia su carga. Envía el tamaño exacto del ZIP en bytes; la respuesta contiene una URL PUT prefirmada por cada parte de 32 MiB (la última, más pequeña), válida durante 1 hora (`upload.expires_at`; nunca más que las credenciales de firma del servidor). Envía con PUT los bytes de cada parte a su URL, guarda el encabezado `ETag` de cada respuesta y después llama a POST /imports/{import_id}/complete en un plazo de 24 horas. Con `matter_id`, la captura aceptada se añade a ese asunto: debes poder añadirle capturas en ese momento (un asunto abierto al que tengas acceso; la importación de un puesto de empresa, solo a un asunto de esa empresa). Cada creación cuenta para el límite diario de 50.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "filename",
                  "size_bytes"
                ],
                "additionalProperties": false,
                "properties": {
                  "filename": {
                    "type": "string",
                    "maxLength": 200,
                    "description": "El nombre de archivo que se mostrará (texto plano: sin caracteres de control, invisibles ni de dirección del texto)."
                  },
                  "size_bytes": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 2147483648,
                    "description": "Tamaño exacto del ZIP en bytes, como máximo 2 GiB."
                  },
                  "matter_id": {
                    "type": "string",
                    "description": "Opcional: añade la captura aceptada a este asunto."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Importación creada; sube las partes.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "import_id": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "uploading"
                      ]
                    },
                    "upload": {
                      "type": "object",
                      "properties": {
                        "upload_id": {
                          "type": "string"
                        },
                        "part_size": {
                          "type": "integer",
                          "example": 33554432
                        },
                        "parts": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "part_number": {
                                "type": "integer"
                              },
                              "url": {
                                "type": "string",
                                "format": "uri",
                                "description": "URL de S3 UploadPart prefirmada: envía con PUT exactamente size_bytes bytes (la URL firma el Content-Length) y lee la cabecera de respuesta ETag. No se necesita ninguna otra cabecera."
                              },
                              "size_bytes": {
                                "type": "integer",
                                "description": "La longitud exacta en bytes de esta parte: part_size para todas las partes salvo la última, el resto para la última (la parte n empieza en (n - 1) x part_size)."
                              }
                            }
                          }
                        },
                        "expires_at": {
                          "type": "string",
                          "format": "date-time"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST (cuerpo, filename, size_bytes, matter_id, campo desconocido).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "PAYMENT_REQUIRED: la suscripción está vencida o impagada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "IMPORT_NOT_ALLOWED (details.reason plan: solo planes Legal; live_credentials_required: una clave de prueba, una sesión sin facturación vinculada o el sandbox), EMAIL_NOT_VERIFIED, FORBIDDEN (importación de un puesto de empresa a un asunto de otra empresa).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "MATTER_NOT_FOUND.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "IMPORT_LIMIT (3 importaciones en curso; details.limit, details.in_progress), MATTER_SEALED, MATTER_ARCHIVED, COMPANY_READ_ONLY.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "IMPORT_TOO_LARGE: size_bytes superior a 2 GiB (details.max_bytes).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "RATE_LIMITED: el límite por minuto, o el límite diario de 50 importaciones (details.action import_create, details.daily_limit).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      },
      "get": {
        "tags": [
          "Importación de paquetes"
        ],
        "operationId": "listImports",
        "summary": "Listar tus importaciones",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "description": "Tus importaciones, de la más reciente a la más antigua. Las importaciones definitivas se conservan durante 30 días (la captura de una importación aceptada se mantiene). Una subida que ha superado sus 24 horas se muestra como expired.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "next_cursor de la página anterior."
          }
        ],
        "responses": {
          "200": {
            "description": "Una página de importaciones.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "imports": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Import"
                      }
                    },
                    "next_cursor": {
                      "type": "string",
                      "nullable": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST (limit, cursor).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/imports/{import_id}": {
      "parameters": [
        {
          "name": "import_id",
          "in": "path",
          "required": true,
          "description": "ID de la importación (imp_ seguido de 16 a 24 letras y dígitos). Un ID mal formado devuelve 404 IMPORT_NOT_FOUND.",
          "schema": {
            "type": "string",
            "pattern": "^imp_[A-Za-z0-9]{16,24}$"
          }
        }
      ],
      "get": {
        "tags": [
          "Importación de paquetes"
        ],
        "operationId": "getImport",
        "summary": "Obtener una importación",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "description": "La importación y, una vez definitiva, su resultado. Consúltala después de POST /imports/{import_id}/complete; la verificación suele tardar unos segundos, un paquete de 2 GiB hasta unos minutos. Con ?upload=true en una subida aún en curso, la respuesta añade upload: URL prefirmadas nuevas (parts) para las partes que el almacenamiento aún no tiene y las partes ya recibidas (received: part_number, etag, size_bytes; null cuando no se pudieron listar, y entonces se vuelven a prefirmar todas las partes), o completed: true cuando la subida ya se había completado (vuelve a enviar complete). Úsalo para reanudar tras un corte de red, una URL de parte caducada (duran como máximo 1 hora, la ventana de subida 24 horas) o una recarga de la página. ?upload=true consume una unidad del límite de frecuencia por minuto.",
        "responses": {
          "200": {
            "description": "La importación.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Import"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "IMPORT_NOT_FOUND (también para la importación de otra cuenta).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "RATE_LIMITED (solo con ?upload=true).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        },
        "parameters": [
          {
            "name": "upload",
            "in": "query",
            "required": false,
            "description": "true (o 1): añade URL de partes nuevas y las partes recibidas de una subida en curso.",
            "schema": {
              "type": "string",
              "enum": [
                "true",
                "1"
              ]
            }
          }
        ]
      },
      "delete": {
        "tags": [
          "Importación de paquetes"
        ],
        "operationId": "cancelImport",
        "summary": "Cancelar una carga",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "description": "Cancela una importación cuya carga sigue en curso: la carga se interrumpe, las partes se eliminan y la importación se quita (después, GET devuelve 404).",
        "responses": {
          "200": {
            "description": "Cancelada.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "import_id": {
                      "type": "string"
                    },
                    "deleted": {
                      "type": "boolean",
                      "enum": [
                        true
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "IMPORT_NOT_FOUND.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "IMPORT_STATE: la importación ya no se está subiendo (details.status).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/imports/{import_id}/complete": {
      "parameters": [
        {
          "name": "import_id",
          "in": "path",
          "required": true,
          "description": "ID de la importación (imp_ seguido de 16 a 24 letras y dígitos). Un ID mal formado devuelve 404 IMPORT_NOT_FOUND.",
          "schema": {
            "type": "string",
            "pattern": "^imp_[A-Za-z0-9]{16,24}$"
          }
        }
      ],
      "post": {
        "tags": [
          "Importación de paquetes"
        ],
        "operationId": "completeImport",
        "summary": "Completar la carga y verificar",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "description": "Completa la carga e inicia la verificación. parts es opcional: envía cada número de parte (de 1 a N, en orden) con el ETag que devolvió el PUT de la parte, u omítelo y el servidor leerá las partes cargadas desde el almacenamiento (útil en un navegador que no puede leer el encabezado ETag). Comprueba que el tamaño almacenado es igual a size_bytes. Consulta GET /imports/{import_id}. Una lista de partes que el almacenamiento rechaza, o una parte aún no cargada (details.missing_parts), devuelve 400 y la carga sigue abierta: carga lo que falta (GET /imports/{import_id}?upload=true proporciona URL nuevas) y vuelve a enviar la solicitud. Volver a enviar complete después de perder una respuesta es seguro: un complete que sigue en ejecución devuelve 409, uno que se interrumpió se retoma al cabo de 2 minutos y una carga ya completada pasa a la verificación. Un tamaño distinto de size_bytes descarta la carga (422 IMPORT_SIZE_MISMATCH; la importación se rechaza, crea una nueva).",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "parts": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "required": [
                        "part_number",
                        "etag"
                      ],
                      "properties": {
                        "part_number": {
                          "type": "integer",
                          "minimum": 1
                        },
                        "etag": {
                          "type": "string",
                          "description": "La cabecera de respuesta ETag del PUT de la parte, con o sin comillas."
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Verificación iniciada.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "import_id": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "verifying"
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST (partes desordenadas o que no coinciden con las partes subidas; o, sin parts, una parte que aún no se ha subido: details.missing_parts).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "PAYMENT_REQUIRED.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "IMPORT_NOT_ALLOWED, EMAIL_NOT_VERIFIED.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "IMPORT_NOT_FOUND.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "IMPORT_STATE: la importación ya no se está subiendo (details.status, por ejemplo verifying o expired).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "IMPORT_SIZE_MISMATCH (details.expected, details.actual).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "RATE_LIMITED.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Envía tu clave de API como `Authorization: Bearer ps_live_...` (el nombre del esquema no distingue mayúsculas de minúsculas). Una cabecera ausente o mal formada devuelve 401 MISSING_API_KEY. Una clave desconocida, revocada o caducada, o una cuenta sin acceso a la API, devuelve 401 INVALID_API_KEY con `WWW-Authenticate: Bearer realm=\"ProofSnap API\", error=\"invalid_token\"`. El 403 se reserva para una credencial válida que no tiene permiso para hacer algo (FORBIDDEN, restricciones del plan). Una clave `ps_live_` de una cuenta sin cuenta de facturación vinculada (cliente de Stripe) funciona como clave de prueba: las capturas llevan marca de agua y nunca se facturan, e include_eidas se rechaza con 403 EIDAS_REQUIRES_LIVE_KEY. El gateway almacena en caché la decisión sobre una clave hasta 60 segundos, por lo que una clave revocada puede seguir funcionando hasta 60 segundos. Si la credencial no se puede comprobar por un fallo temporal del backend, el gateway devuelve en su lugar 503 SERVICE_UNAVAILABLE; esa respuesta no se almacena en caché, así que vuelve a intentarlo con espera exponencial. El desafío `WWW-Authenticate` se devuelve sin cambios en un 401 del gateway (la comprobación de la credencial); en un 401 de un controlador de ruta, el gateway entrega el mismo desafío renombrado como `x-amzn-Remapped-WWW-Authenticate`."
      }
    },
    "headers": {
      "XRequestId": {
        "description": "ID de la solicitud, útil también en las solicitudes de soporte. Se envía en las respuestas correctas; los cuerpos de error lo llevan como request_id.",
        "schema": {
          "type": "string"
        }
      },
      "RateLimitLimit": {
        "description": "Solicitudes permitidas en la ventana actual de 60 segundos. Cabecera heredada, mantenida junto a RateLimit / RateLimit-Policy.",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimitRemaining": {
        "description": "Solicitudes restantes en la ventana actual. Cabecera heredada, mantenida junto a RateLimit / RateLimit-Policy.",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimitReset": {
        "description": "Hora ISO 8601 en la que termina la ventana actual. Cabecera heredada, que se mantiene junto a RateLimit / RateLimit-Policy.",
        "schema": {
          "type": "string",
          "format": "date-time"
        }
      },
      "RetryAfter": {
        "description": "Segundos que hay que esperar antes de volver a intentarlo.",
        "schema": {
          "type": "integer"
        }
      },
      "QuotaLimit": {
        "description": "Capturas incluidas en el plan al mes (claves de producción en planes con cuota mensual).",
        "schema": {
          "type": "integer"
        }
      },
      "QuotaRemaining": {
        "description": "Capturas incluidas restantes este mes.",
        "schema": {
          "type": "integer"
        }
      },
      "QuotaUsed": {
        "description": "Capturas completadas este mes.",
        "schema": {
          "type": "integer"
        }
      },
      "QuotaCap": {
        "description": "El máximo mensual que se aplica a quien llama (valor predeterminado del plan, límite propio o límite de la empresa).",
        "schema": {
          "type": "integer"
        }
      },
      "QuotaOrgLimit": {
        "description": "Solo miembros de una empresa: el límite mensual de toda la empresa, cuando el propietario ha fijado uno.",
        "schema": {
          "type": "integer"
        }
      },
      "QuotaOrgUsed": {
        "description": "Solo miembros de una empresa: capturas admitidas para toda la empresa este mes.",
        "schema": {
          "type": "integer"
        }
      },
      "SandboxLimit": {
        "description": "Sandbox (sesión del panel sin plan): asignación total de capturas de prueba con marca de agua (50).",
        "schema": {
          "type": "integer"
        }
      },
      "SandboxRemaining": {
        "description": "Sandbox: asignación restante.",
        "schema": {
          "type": "integer"
        }
      },
      "SandboxUsed": {
        "description": "Sandbox: asignación usada.",
        "schema": {
          "type": "integer"
        }
      },
      "IdempotentReplay": {
        "description": "\"true\" cuando esta respuesta reproduce una solicitud anterior con la misma Idempotency-Key.",
        "schema": {
          "type": "string",
          "enum": [
            "true"
          ]
        }
      },
      "WWWAuthenticate": {
        "description": "Desafío RFC 6750 enviado con cada 401. En un 401 devuelto por un controlador de ruta y no por el gateway, API Gateway lo entrega renombrado como `x-amzn-Remapped-WWW-Authenticate`. Una clave de API no válida, revocada o caducada, o un token de sesión incorrecto o revocado (INVALID_API_KEY): `Bearer realm=\"ProofSnap API\", error=\"invalid_token\"`. Una cabecera Authorization ausente o mal formada (MISSING_API_KEY): `Bearer realm=\"ProofSnap API\"`.",
        "schema": {
          "type": "string"
        },
        "example": "Bearer realm=\"ProofSnap API\", error=\"invalid_token\""
      },
      "RateLimitPolicy": {
        "description": "Política del límite de frecuencia, según el borrador IETF draft-ietf-httpapi-ratelimit-headers (forma de campo estructurado; al basarse en un borrador, puede cambiar con él): `\"default\";q=<requests per window>;w=60`.",
        "schema": {
          "type": "string"
        },
        "example": "\"default\";q=120;w=60"
      },
      "RateLimit": {
        "description": "Estado actual del límite de frecuencia, según el borrador IETF draft-ietf-httpapi-ratelimit-headers (basado en el borrador): `\"default\";r=<requests left>;t=<seconds until the window resets>`. Se envía en las respuestas de las rutas con límite de frecuencia (capturas individuales y por lotes, compare, redact, certify-hash) después de ejecutarse el limitador, no solo con 429 (una solicitud rechazada antes del limitador, por ejemplo por autenticación, no lo incluye).",
        "schema": {
          "type": "string"
        },
        "example": "\"default\";r=117;t=42"
      }
    },
    "parameters": {
      "CaptureId": {
        "name": "capture_id",
        "in": "path",
        "required": true,
        "description": "ID de captura, certificado, comparación, ocultación o paquete importado. Los ID empiezan por cap_, cert_, cmp_, red_ o imp_. Un ID con formato incorrecto devuelve 404 CAPTURE_NOT_FOUND.",
        "schema": {
          "type": "string",
          "example": "cap_a1B2c3D4e5F6g7H8"
        }
      },
      "ScheduleId": {
        "name": "schedule_id",
        "in": "path",
        "required": true,
        "description": "ID de la programación. Una programación de otra cuenta devuelve 404 SCHEDULE_NOT_FOUND.",
        "schema": {
          "type": "string",
          "example": "sch_a1B2c3D4e5F6g7H8"
        }
      },
      "MatterId": {
        "name": "matter_id",
        "in": "path",
        "required": true,
        "description": "ID del asunto. Un asunto del que no eres miembro devuelve 404 MATTER_NOT_FOUND.",
        "schema": {
          "type": "string",
          "example": "mat_a1B2c3D4e5F6g7H8"
        }
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "Clave opcional del cliente que hace seguro un reintento. Solo se usan los primeros 255 caracteres, y las claves se asocian a tu cuenta durante 24 horas. Un reintento con la misma clave y el mismo cuerpo devuelve el cuerpo de la respuesta original con `Idempotent-Replay: true` (202 para una captura individual, un lote o una comparación, 201 para una ocultación; `status` se actualiza al estado actual de la captura, la comparación o la ocultación, y de cada captura de un lote); no se crea ni se cobra ninguna captura nueva. La misma clave con un cuerpo distinto devuelve 422 IDEMPOTENCY_KEY_REUSED. Un reintento mientras la primera solicitud aún se está aceptando devuelve 409 IDEMPOTENCY_IN_PROGRESS con el ID original en `error.details.capture_id` (el batch_id en el caso de un lote).",
        "schema": {
          "type": "string",
          "maxLength": 255
        }
      },
      "SubscriptionId": {
        "name": "subscription_id",
        "in": "path",
        "required": true,
        "description": "ID de la suscripción de webhook. Una suscripción de otra cuenta devuelve 404 WEBHOOK_SUBSCRIPTION_NOT_FOUND.",
        "schema": {
          "type": "string",
          "example": "whs_a1B2c3D4e5F6g7H8"
        }
      }
    },
    "responses": {
      "RateLimited": {
        "description": "RATE_LIMITED: demasiadas solicitudes en la ventana actual de 60 segundos. `error.details.retry_after_seconds` repite Retry-After. RateLimit indica r=0 y los segundos que faltan para que se restablezca la ventana.",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          },
          "RateLimit-Policy": {
            "$ref": "#/components/headers/RateLimitPolicy"
          },
          "RateLimit": {
            "$ref": "#/components/headers/RateLimit"
          },
          "X-RateLimit-Limit": {
            "$ref": "#/components/headers/RateLimitLimit"
          },
          "X-RateLimit-Remaining": {
            "$ref": "#/components/headers/RateLimitRemaining"
          },
          "X-RateLimit-Reset": {
            "$ref": "#/components/headers/RateLimitReset"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "RATE_LIMITED",
                "message": "Too many requests. See Retry-After header.",
                "details": {
                  "retry_after_seconds": 42
                }
              },
              "request_id": "..."
            }
          }
        }
      },
      "InternalError": {
        "description": "INTERNAL_ERROR: error inesperado del servidor. Reintenta con retroceso exponencial.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "AuthUnavailable": {
        "description": "SERVICE_UNAVAILABLE: el gateway no pudo comprobar la credencial (fallo temporal del backend). No se almacena en caché: vuelve a intentarlo con espera exponencial.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "SERVICE_UNAVAILABLE",
                "message": "Authentication is temporarily unavailable. Please retry."
              },
              "request_id": "..."
            }
          }
        }
      },
      "EidasOrAuthUnavailable": {
        "description": "EIDAS_UNAVAILABLE: se solicitó include_eidas pero el sellado cualificado de tiempo no está configurado en este despliegue; reintenta sin include_eidas. SERVICE_UNAVAILABLE: la pasarela no pudo comprobar la credencial (fallo temporal del backend, no se almacena en caché); reintenta con espera progresiva (backoff).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "401, dos códigos. MISSING_API_KEY: falta el encabezado Authorization o tiene un formato incorrecto (`WWW-Authenticate: Bearer realm=\"ProofSnap API\"`). INVALID_API_KEY: la credencial se envió pero no es válida: una clave de API desconocida, revocada o caducada, una cuenta sin acceso a la API, o un token de sesión no válido o revocado (`WWW-Authenticate: Bearer realm=\"ProofSnap API\", error=\"invalid_token\"`). No reintentes con la misma credencial. 403 significa que la credencial es válida pero no tiene permiso para esta operación (FORBIDDEN, restricciones del plan).",
        "headers": {
          "WWW-Authenticate": {
            "$ref": "#/components/headers/WWWAuthenticate"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "missing": {
                "value": {
                  "error": {
                    "code": "MISSING_API_KEY",
                    "message": "Missing or malformed Authorization header. Send Authorization: Bearer <your API key>."
                  },
                  "request_id": "..."
                }
              },
              "invalid": {
                "value": {
                  "error": {
                    "code": "INVALID_API_KEY",
                    "message": "The API key is invalid, revoked or expired, or the account has no API access."
                  },
                  "request_id": "..."
                }
              }
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "description": "Todos los errores tienen esta forma. Las respuestas de error incluyen el encabezado `X-Request-ID` con el mismo valor que `request_id`.\n\nTamaño de la solicitud: el cuerpo de una solicitud puede ocupar como máximo 6 MB (el límite de carga útil de Lambda; API Gateway se detiene en 10 MB). Un cuerpo mayor se rechaza antes de llegar al código de la API, por lo que esa respuesta no tiene esta forma.\n\n| HTTP | code | Significado |\n| --- | --- | --- |\n| 400 | `MALFORMED_REQUEST` | El cuerpo no es JSON válido (mensaje \"Request body is not valid JSON\") o la solicitud no es válida: cuando `details.reason` indica qué campo y por qué, `message` repite ese motivo; en caso contrario es \"The request is invalid.\" |\n| 400 | `MISSING_FIELD` | Falta un campo obligatorio. |\n| 400 | `INVALID_URL` | La URL no es una URL http(s) válida, tiene más de 2048 caracteres, o un webhook_url no es https. |\n| 400 | `INVALID_HASH` | certify-hash: un hash no tiene 64 caracteres hexadecimales. |\n| 400 | `INVALID_TIMESTAMP_TYPE` | options.timestamp_type no es \"bitcoin\". |\n| 400 | `INVALID_VIEWPORT` | options.viewport no es \"desktop\" ni \"mobile\". |\n| 400 | `METADATA_TOO_LARGE` | options.metadata tiene más de 10 claves, un valor que no es una cadena o un valor de más de 500 caracteres. |\n| 400 | `INVALID_EVIDENCE_PACK_OPTIONS` | options.evidence_pack no es válido. |\n| 400 | `INVALID_BATCH` | El campo urls del lote no es un array de 2 a 50 URL válidas. |\n| 400 | `REDACTION_NO_OPS` | La ocultación no tiene operaciones, o ninguna de ellas coincidió con nada. |\n| 400 | `INVALID_CRON` | No se pudo analizar cron_expr. |\n| 400 | `INTERVAL_TOO_SHORT` | La cadencia de la programación es inferior al mínimo del plan. |\n| 400 | `RATE_LIMIT_COST_TOO_HIGH` | La solicitud necesita más unidades del límite de frecuencia de las que permite una ventana; `details.max_per_window`, `details.window_seconds`, `details.requested`. Divídela. |\n| 400 | `WEBHOOK_REQUIRES_API_KEY` | Se envió un `webhook_url` desde una sesión del panel. Los webhooks se firman con el secreto de firma del webhook de una clave de API, por lo que necesitan una clave de API. |\n| 401 | `MISSING_API_KEY` | Falta el encabezado Authorization o tiene un formato incorrecto. `WWW-Authenticate: Bearer realm=\"ProofSnap API\"`. |\n| 401 | `INVALID_API_KEY` | Clave desconocida, revocada o caducada, sin acceso a la API, o token de sesión no válido o revocado. `WWW-Authenticate: Bearer realm=\"ProofSnap API\", error=\"invalid_token\"`. |\n| 402 | `QUOTA_EXCEEDED` | Se alcanzó el máximo mensual o la asignación del sandbox (también en el `error` de una certificación en segundo plano rechazada por ese motivo). |\n| 402 | `PAYMENT_REQUIRED` | La suscripción de producción está vencida o impagada (también en el `error` de una captura automática omitida por ese motivo). |\n| 403 | `FORBIDDEN` | El recurso pertenece a otra persona, o la acción está reservada al propietario. Mensaje: \"You do not have access to this resource.\" (details.reason amplía la información cuando es útil). |\n| 403 | `EMAIL_NOT_VERIFIED` | Una sesión del panel cuya dirección de correo electrónico no está confirmada intentó crear algo persistente, de pago o que invita a personas (capturas, lotes, comparaciones, ocultaciones, claves de API, secretos de firma del webhook, checkout, cambios de plan, cambios de puestos, certify-hash, programaciones, invitaciones a la empresa, asuntos y cualquier cambio en ellos, miembros de asuntos, capturas asignadas a asuntos, enlaces compartidos y su revocación, sellos, exportaciones, retenciones legales). Las lecturas siguen permitidas. Mensaje: \"Confirm your email address first. Use the link we sent you, or send a new one from the dashboard.\" Una clave de API se rechaza del mismo modo cuando la dirección de correo electrónico de su cuenta no está confirmada. |\n| 403 | `EIDAS_REQUIRES_LIVE_KEY` | include_eidas enviado con una clave de prueba (una clave ps_live_ de una cuenta sin una cuenta de facturación vinculada cuenta como tal), o para una comparación u ocultación de una captura de prueba (`details.reason`). Una clave de producción o una sesión que funciona como prueba porque no hay ninguna cuenta de facturación vinculada recibe `details.reason` \"no_billing_account\". |\n| 403 | `MATTERS_NOT_ENABLED` | Los asuntos requieren un plan Legal. |\n| 403 | `SCHEDULE_LIMIT_REACHED` | Se alcanzó el límite de programaciones activas del plan (o el plan no incluye programaciones). |\n| 403 | `ON_CHANGE_REQUIRES_PLAN` | El plan no permite la supervisión on_change. |\n| 403 | `PLAN_ARCHIVE_ONLY` | El plan Archive only mantiene tus pruebas legibles, descargables y verificables; no crea capturas, programaciones, certificaciones, comparaciones ni ocultaciones. |\n| 403 | `PLAN_NOT_AVAILABLE` | Esta cuenta no puede elegir el plan: Archive only se compra después de que haya terminado una suscripción de pago a la API (mientras las pruebas sigan conservadas) o se obtiene cambiando de plan desde developer, startup, growth, website_archive o legal_solo; el archivo de empresa, solo el propietario de una empresa cuya suscripción terminó, dentro de su periodo de descarga. `details.reason` indica el motivo. |\n| 403 | `PLAN_API_READ_ONLY` | Plan Website Archive: la clave de API es de solo lectura (enumerar, descargar, verificar, webhooks). Crea capturas, programaciones y certificaciones en el panel, o usa un plan de API. |\n| 403 | `LEGAL_HOLD_OWNER_ONLY` | Solo el propietario de la empresa libera la retención legal de una captura de la empresa (un puesto puede aplicarla). |\n| 403 | `LEGAL_HOLD_NOT_AVAILABLE` | Aplicar una retención legal requiere un plan de pago (`details.reason` \"plan\": el sandbox gratuito, o una cuenta cuya suscripción terminó) y una captura de producción (`details.reason` \"test_capture\": una captura en modo de prueba con marca de agua). Liberar una retención nunca se rechaza por este motivo. |\n| 403 | `IMPORT_NOT_ALLOWED` | La importación de paquetes requiere un plan Legal (`details.reason` \"plan\") y una clave de API de producción o una sesión del panel con facturación vinculada (`details.reason` \"live_credentials_required\"). |\n| 404 | `CAPTURE_NOT_FOUND` | La captura no existe, se eliminó o el ID tiene un formato incorrecto. |\n| 404 | `MATTER_NOT_FOUND` | El asunto no existe o no eres miembro. |\n| 404 | `IMPORT_NOT_FOUND` | La importación no existe, pertenece a otra cuenta o el ID tiene un formato incorrecto. |\n| 404 | `WEBHOOK_SUBSCRIPTION_NOT_FOUND` | La suscripción de webhook no existe, se eliminó o pertenece a otra cuenta. |\n| 404 | `SCHEDULE_NOT_FOUND` | La programación no existe o pertenece a otra cuenta. |\n| 404 | `NOT_FOUND` | No se encontró otro recurso (formato del hash del registro de emisión, token de enlace compartido, trabajo de exportación, miembro). |\n| 404 | `EXPORT_JOB_NOT_FOUND` | Un trabajo de exportación de capturas no existe, pertenece a otra cuenta, ha caducado o se solicitó con una clave de prueba. |\n| 405 | `METHOD_NOT_ALLOWED` | El método HTTP no está permitido en esta ruta. |\n| 409 | `CAPTURE_PROCESSING` | La captura sigue en proceso y todavía no se puede eliminar (ni cambiar su retención legal). |\n| 409 | `CAPTURE_LEGAL_HOLD` | La captura está sujeta a retención legal y no se puede eliminar. |\n| 409 | `CAPTURE_IN_TRASH` | Retención legal: la captura está en la papelera; restáurala primero. |\n| 409 | `CAPTURE_HOLD_GRACE` | Eliminación permanente dentro del periodo de gracia posterior a la liberación de la retención legal de la captura; `details.until`. Se permite moverla a la papelera. |\n| 409 | `LEGAL_HOLD_MATTER` | La retención de la captura la aplicó la retención legal de su asunto, que sigue activa: libérala en el asunto (`details.matter_id` para quien puede leer el asunto). |\n| 409 | `LEGAL_HOLD_SOURCE` | La retención de una ocultación o comparación sigue a una captura a partir de la cual se hizo (en una comparación, cualquiera de las capturas comparadas), que sigue sujeta a retención legal (aplicada a esa captura o a través de su asunto): el paquete permanece retenido hasta que se libere esa retención (`details.source_capture_id`). |\n| 409 | `COMPANY_READ_ONLY` | La suscripción de la empresa ha terminado (o se conserva como archivo de empresa): sus asuntos se pueden abrir y exportar y se pueden revocar sus enlaces compartidos, nada más, y no se deriva nada nuevo de sus capturas. |\n| 409 | `WEBHOOK_SUBSCRIPTION_LIMIT` | Se alcanzó el límite de suscripciones de webhook activas del plan (25 en los planes de API y Legal, 10 en Website Archive, 0 en Archive only y sandbox); `details.limit`, `details.active`. |\n| 409 | `WEBHOOK_SECRET_ROTATION_PENDING` | El secreto de firma del webhook anterior sigue dentro de su periodo de solapamiento de 24 horas; `details.previous_secret_expires_at`. |\n| 409 | `IDEMPOTENCY_IN_PROGRESS` | La primera solicitud con esta Idempotency-Key todavía se está aceptando. |\n| 409 | `COMPARE_NOT_READY` | Ambas capturas deben estar completadas antes de compararlas. |\n| 409 | `REDACTION_NOT_READY` | La captura de origen debe estar completada antes de ocultar datos. |\n| 409 | `MATTER_SEALED` | El asunto (o el asunto de la captura) está sellado y no puede cambiar. |\n| 409 | `MATTER_SEALING` | Hay un sellado del asunto en curso (como máximo 60 s): mientras tanto, no se permite asignar capturas, renombrar, cambiar miembros, enviar a la papelera una captura vinculada ni un segundo sellado. `Retry-After`. |\n| 409 | `MATTER_ARCHIVED` | El asunto está archivado: no admite capturas nuevas, cambios de nombre, miembros nuevos, uso compartido ni exportación; vuelve a abrirlo primero (PATCH status \"open\"). Quitar una captura o un miembro sigue funcionando. |\n| 409 | `MATTER_CHANGED` | El registro de la cadena de custodia cambió durante el sellado; no se ha sellado nada, reintenta. |\n| 409 | `MATTER_LINKED` | Eliminación de captura: la captura es un anexo de un asunto al que quien llama ya no pertenece. El propietario del asunto debe quitarla primero del asunto. |\n| 409 | `MATTER_HAS_TRASHED_CAPTURES` | Sello del asunto: una captura del asunto está en la papelera. Restáurala o quítala del asunto y vuelve a sellar. |\n| 409 | `MEMBER_HAS_INDIVIDUAL_SUBSCRIPTION` | Invitación a la empresa: la suscripción propia a la API de la persona invitada sigue renovándose. Debe cancelarla y después el propietario vuelve a invitarla. |\n| 409 | `IMPORT_LIMIT` | Ya hay 3 importaciones en curso (cargándose o verificándose); `details.limit`, `details.in_progress`. |\n| 409 | `IMPORT_STATE` | La importación no está en un estado que lo permita (complete o cancel de una importación que ya no se está cargando); `details.status`. |\n| 409 | `EXPORT_JOB_RUNNING` | Todavía se está preparando una exportación de tus capturas; `details.job_id` y `details.poll_path` la identifican. Inicia una nueva cuando haya terminado. |\n| 413 | `IMPORT_TOO_LARGE` | El paquete supera los 2 GiB; `details.max_bytes`. |\n| 413 | `VERIFY_PACKAGE_TOO_LARGE` | POST /captures/{capture_id}/verify: el paquete almacenado supera los 256 MiB; verifícalo sin conexión con los scripts incluidos. `details.size_bytes`, `details.limit_bytes`. |\n| 422 | `IDEMPOTENCY_KEY_REUSED` | Idempotency-Key reutilizada con un cuerpo distinto o con un entorno de clave distinto (producción o prueba). |\n| 422 | `SSRF_BLOCKED` | La URL apunta a una dirección privada, de loopback, link-local o bloqueada por otro motivo. |\n| 422 | `TARGET_UNREACHABLE` | El nombre de host de la URL no tiene registros DNS o no se pudo resolver. |\n| 422 | `REDACTION_SCREENSHOT_UNREADABLE` | No se pudo decodificar la captura de pantalla para la ocultación con recuadros. |\n| 422 | `REDACTION_LEAK_DETECTED` | Un término oculto sobrevivió a la ocultación fuera de la URL de captura; no se ha emitido nada. `details.files`, `details.rule_indexes`, `details.metadata_fields`, `details.in_capture_url`. |\n| 422 | `REDACTION_SOURCE_UNSUPPORTED` | Solo se pueden ocultar datos de capturas de páginas web. `details.source` o `details.manifest_type`. |\n| 422 | `REDACTION_SOURCE_INTEGRITY_FAILED` | Un archivo de la captura de origen no coincide con el SHA-256 que indica su manifiesto firmado, o falta; no se ha emitido nada. `details.mismatched`, `details.missing`. |\n| 422 | `COMPARE_PROFILE_MISMATCH` | POST /compare: las dos capturas se hicieron con un perfil de dispositivo distinto o con una configuración de captura distinta; `details.differing`, `details.before`, `details.after`. Envía allow_profile_mismatch: true para comparar de todos modos (el resultado de texto e imágenes es entonces indeterminado). |\n| 422 | `COMPARE_URL_MISMATCH` | POST /compare: las dos capturas son de páginas distintas; `details.before`, `details.after` (url y final_url como origen y ruta). Envía allow_url_mismatch: true para comparar de todos modos. |\n| 422 | `RULE902_NOT_AVAILABLE_FOR_MISMATCH` | POST /compare: include_rule_902 para capturas renderizadas de forma distinta (allow_profile_mismatch); `details.differing`. |\n| 422 | `COMPARE_SOURCE_INTEGRITY_FAILED` | Un archivo de una captura de origen de POST /compare no coincide con el SHA-256 que indica su manifiesto firmado, o falta; no se ha comparado ni cobrado nada. `details.capture_id`, `details.mismatched`, `details.missing`, `details.sources[]`. |\n| 422 | `REDACTION_PATTERN_TOO_SLOW` | Un patrón regex de text_redactions tardó demasiado en esta página (backtracking catastrófico); no se ha emitido ni cobrado nada. `details.rule_index`. Simplifica el patrón. |\n| 422 | `IMPORT_SIZE_MISMATCH` | El paquete cargado no tiene el size_bytes anunciado; la carga se ha descartado. `details.expected`, `details.actual`. |\n| 422 | `IMPORT_NOT_SUPPORTED` | Comparación u ocultación de un paquete de pruebas importado. |\n| 429 | `RATE_LIMITED` | Demasiadas solicitudes en la ventana actual, o se alcanzó un límite diario de creación de asuntos (200), sellos (20), exportaciones (50), invitaciones de miembros (50) o importaciones de paquetes (50, `import_create`) (`details.daily_limit`, `details.action`). `Retry-After` indica cuándo reintentar. |\n| 429 | `CONCURRENT_CAPTURE_LIMIT` | Demasiadas capturas de la cuenta en ejecución a la vez (developer 10, startup 25, growth 100, legal_solo y legal_seats 25, custom 200, website_archive 25, sandbox 5). Un lote inicia tantas URL como quepan y enumera el resto en `not_admitted`; si no hay ningún hueco libre, se rechaza la solicitud completa. `details.limit`, `details.in_flight`, `details.requested`, `Retry-After`. |\n| 429 | `TEST_MODE_DAILY_LIMIT` | El trabajo en modo de prueba (una clave de prueba o una sesión sin facturación vinculada; con marca de agua, no se factura) está limitado a 200 unidades por cuenta y día UTC (un lote cuenta sus URL). `details.used`, `details.limit`, `details.reset_at`, `Retry-After`. |\n| 429 | `IMPORT_DAILY_BYTES_LIMIT` | Importación de paquetes: se alcanzó el volumen de importación de la cuenta del día UTC (5 GiB de tamaños anunciados). `details.period`, `details.limit_bytes`, `details.used_bytes`, `details.requested_bytes`, `details.reset_at`. |\n| 429 | `IMPORT_MONTHLY_BYTES_LIMIT` | Importación de paquetes: se alcanzó el volumen de importación de la cuenta del mes UTC (20 GiB). Mismos detalles. |\n| 500 | `INTERNAL_ERROR` | Error inesperado. Reintenta con espera progresiva (backoff). |\n| 500 | `CAPTURE_FAILED` | No se pudo iniciar la captura o (en el `error` de una captura fallida) no se pudo completar. |\n| 500 | `CAPTURE_TIME_BUDGET_EXCEEDED` | En el `error` de una captura fallida, o en una ocultación síncrona: la ejecución agotó su presupuesto de tiempo y no se emitió ni cobró ningún paquete. |\n| 500 | `CAPTURE_SCREENSHOT_TIMEOUT` | En el `error` de una captura fallida: el navegador no terminó de renderizar la captura de pantalla: todas las formas de obtenerla (de una vez, en franjas más pequeñas, la captura de pantalla propia del navegador) agotaron el tiempo de espera, al igual que el único reintento en un navegador recién iniciado cuando el presupuesto de tiempo lo permitía. No se ha cobrado nada; vuelve a intentarlo. |\n| 500 | `CERTIFY_FAILED` | En el `error` de una certificación fallida: no se pudo completar la certificación. |\n| 500 | `REDACTION_FAILED` | En el `error` de una ocultación fallida: no se pudo completar la ocultación. Una ocultación rechazada por un motivo conocido lleva en su lugar el código de ese motivo (por ejemplo, REDACTION_LEAK_DETECTED). |\n| 500 | `TIMESTAMP_SERVICE_UNAVAILABLE` | Sello del asunto: el anclaje de OpenTimestamps falló; no ha cambiado nada, reintenta. |\n| 500 | `CONFIGURATION_ERROR` | Un paso del servidor falló o no está configurado (`details.reason`). |\n| 503 | `SERVICE_UNAVAILABLE` | La autenticación no está disponible temporalmente (no se pudo comprobar la credencial). No se almacena en caché; reintenta. |\n| 503 | `EIDAS_UNAVAILABLE` | Se solicitó include_eidas pero el sellado de tiempo eIDAS no está configurado. |\n| 503 | `EIDAS_SEAL_RETRY` | Sello del asunto: el sello de tiempo eIDAS no llegó a tiempo; no se ha sellado nada. Reintenta después de `Retry-After`; el reintento reutiliza el sello de tiempo ya emitido. |\n| 503 | `LIMIT_CHECK_UNAVAILABLE` | Sello del asunto: no se pudo comprobar su límite diario; no se ha hecho nada. Reintenta. |\n| 504 | `UPSTREAM_TIMEOUT` | Un paso de procesamiento síncrono (certify-hash, exportación de asunto) no respondió a tiempo. Puede que aún se complete: consulta lo que indique `details` (`certify_id`, `poll_path`) o reintenta. |\n| 404 | `ORG_INVITE_NOT_FOUND` | Una invitación a la empresa desconocida, dirigida a otra persona, que ya no está pendiente o cuya empresa ya no está activa (details.reason `company_inactive`). |\n| 409 | `ORG_INVITE_NO_SEATS` | Aceptación de una invitación a la empresa: todos los puestos de la empresa están en uso; la invitación sigue abierta. |\n| 409 | `ORG_INVITE_IN_OTHER_ORG` | Aceptación de una invitación a la empresa: quien llama ocupa un puesto de otra empresa; la invitación sigue abierta. |\n| 409 | `ORG_INVITE_HAS_SUBSCRIPTION` | Aceptación de una invitación a la empresa: la suscripción propia a la API de quien llama sigue renovándose; la invitación sigue abierta. |\n| 409 | `ORG_OWNER_CANNOT_LEAVE` | El propietario de la empresa intentó abandonar su propia empresa. |\n| 410 | `ORG_INVITE_EXPIRED` | Aceptación de una invitación a la empresa de más de 7 días de antigüedad. |",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string",
                "description": "Código de error legible por máquina (UPPER_SNAKE_CASE)."
              },
              "message": {
                "type": "string",
                "description": "Mensaje legible por humanos."
              },
              "details": {
                "type": "object",
                "additionalProperties": true,
                "description": "Contexto adicional opcional, por ejemplo `reason`, `field` o `retry_after_seconds`."
              }
            },
            "required": [
              "code",
              "message"
            ]
          },
          "request_id": {
            "type": "string",
            "description": "ID de la solicitud."
          }
        },
        "example": {
          "error": {
            "code": "INVALID_URL",
            "message": "URL is not a valid http(s) URL",
            "details": {
              "reason": "Only http and https schemes are allowed"
            }
          },
          "request_id": "..."
        }
      },
      "CaptureOptions": {
        "type": "object",
        "description": "Opciones de captura. Todos los campos son opcionales.",
        "properties": {
          "scroll_full_page": {
            "type": "boolean",
            "description": "Captura toda la página desplazable. false captura solo el área visible (viewport).",
            "default": true
          },
          "expand_collapsed": {
            "type": "boolean",
            "description": "Abre las secciones contraídas antes de la captura de pantalla: elementos <details> cerrados, botones de acordeón y de despliegue (aria-expanded con aria-controls) y controles de texto \"see more\" / \"show replies\". Con salvaguardas: nada dentro de navegaciones, encabezados, menús o diálogos, ningún enlace a otra URL, ningún envío de formularios, ninguna etiqueta que cambie un estado (like, follow, buy, delete, ...), cada control como máximo una vez, y un control que intente abandonar la página detiene la expansión y se vuelve a cargar la URL original. El resultado se registra por control en forensic_log.json (page_modifications.expand_collapsed: encontrados, abiertos, dejados cerrados, motivos de omisión, solicitudes iniciadas, altura de la página antes y después) con un resumen en metadata.json (expand_collapsed: requested, opened_total, left_closed_total); evidence.pdf y los certificados indican solo lo que realmente se abrió.",
            "default": true
          },
          "viewport": {
            "type": "string",
            "description": "desktop es 1920x1080 con pixel ratio 1; mobile es 390x844 con pixel ratio 3 y un user agent de Safari para móviles. Otros valores devuelven 400 INVALID_VIEWPORT.",
            "enum": [
              "desktop",
              "mobile"
            ],
            "default": "desktop"
          },
          "viewport_width": {
            "type": "number",
            "minimum": 320,
            "maximum": 3840,
            "description": "Anchura personalizada del área visible en píxeles CSS. Debe enviarse junto con viewport_height; de lo contrario, 400 MALFORMED_REQUEST."
          },
          "viewport_height": {
            "type": "number",
            "minimum": 240,
            "maximum": 4320,
            "description": "Altura personalizada del área visible en píxeles CSS. Debe enviarse junto con viewport_width."
          },
          "device_scale_factor": {
            "type": "number",
            "minimum": 1,
            "maximum": 3,
            "description": "Relación de píxeles del dispositivo de la captura de pantalla."
          },
          "wait_for_selector": {
            "type": "string",
            "description": "Espera hasta 10 segundos a que aparezca este selector CSS antes de capturar. Si no aparece, la captura continúa sin error. Se registra en forensic_log.json page_modifications.",
            "maxLength": 200
          },
          "wait_ms": {
            "type": "number",
            "minimum": 0,
            "maximum": 15000,
            "description": "Espera adicional en milisegundos después de que la página haya cargado."
          },
          "screenshot_format": {
            "type": "string",
            "description": "jpeg entrega screenshot.jpeg; png entrega un screenshot.png sin pérdida. Las C2PA Content Credentials solo se añaden a las capturas de pantalla JPEG.",
            "enum": [
              "jpeg",
              "png"
            ],
            "default": "jpeg"
          },
          "url_stamp": {
            "type": "boolean",
            "description": "Añade sobre la imagen de la página una franja de encabezado con la URL capturada, la hora de captura en UTC (el momento en que el navegador generó la captura de pantalla; en una página larga capturada por franjas, el momento en que se generó la última franja) y el ID de la prueba, identificada como anotación de software. La franja se añade encima de la imagen, por lo que no se tapa ningún píxel de la página. chain_of_custody.json registra screenshot_url_header.raw_screenshot_sha256 con raw_screenshot_source: para una página capturada de una sola vez (browser_image, browser_png, browser_jpeg_joined, browser_jpeg_reencoded), el SHA-256 de la imagen de la página que generó el navegador, antes de la franja; para una página larga capturada por franjas y unida (joined_bands, joined_bands_lossless, joined_bands_reencoded), el SHA-256 de la imagen unida tal como ProofSnap la escribió con la franja, antes de cualquier Content Credentials de C2PA. Cuando no se puede añadir la franja (con ella la imagen superaría los 65.535 px que admite un JPEG, no se pudo dibujar o, solo en la ruta alternativa de captura de pantalla, la imagen de la página supera los 32.000.000 píxeles), la captura de pantalla se entrega sin ella: metadata.json lo registra en screenshot.url_stamp_skipped y chain_of_custody.json en screenshot_url_header (applied false, con el motivo).",
            "default": false
          },
          "network_log": {
            "type": "boolean",
            "description": "Incluye network.har (HAR con cabeceras de solicitud y de respuesta y tiempos, sin los cuerpos) y network.original.har.",
            "default": true
          },
          "warc": {
            "type": "boolean",
            "description": "Incluye capture.warc (ISO 28500) con las transacciones HTTP de la carga de la página. Los cuerpos de las respuestas se leen dentro de un límite de tiempo (como máximo 30 s, y solo mientras lo permita la parte opcional del presupuesto de tiempo de la captura, después del tiempo reservado para eIDAS, C2PA y la lectura final del reloj); un cuerpo cortado por ese límite se guarda vacío con WARC-Truncated: time, una respuesta no alcanzada se omite, y warc_captured en chain_of_custody.json (y warc en forensic_log.json) registra truncated, truncation_reason, response_pairs, records_total y los recuentos.",
            "default": false
          },
          "external_archive": {
            "type": "boolean",
            "description": "Pide al servicio Save Page Now de Internet Archive que archive la URL (Internet Archive conserva esa copia de forma pública). El resultado se clasifica según qué solicitud produjo la instantánea y su hora respecto a la solicitud: created (creada para esta solicitud), recent_existing (Internet Archive respondió con una instantánea anterior), created_after_request (existe una instantánea más reciente, pero la solicitud no se confirmó), existing (solo existe una instantánea más antigua), pending, rate_limited, failed (Internet Archive rechazó la solicitud), unconfirmed (ninguna respuesta la confirmó ni la rechazó y no se conoce ninguna instantánea), unavailable, skipped, skipped_sensitive o skipped_query. Las URL que parecen contener credenciales o que llevan parámetros, nombres de parámetros o segmentos de ruta similares a tokens nunca se envían. Una URL con cadena de consulta solo se envía cuando esta opción se establece explícitamente en true; con el valor predeterminado se omite (skipped_query). El resultado se registra en forensic_log.json (external_archive, con snapshot_offset_seconds respecto a la captura) y en chain_of_custody.json, y GET /captures/{capture_id} lo devuelve como external_archive. Establece false para las URL privadas. La solicitud se envía en cuanto la página se ha cargado, en paralelo con el resto de la captura. Un fallo no hace fallar la captura.",
            "default": true
          },
          "dismiss_consent": {
            "type": "boolean",
            "description": "Establece cookies de consentimiento \"aceptar todo\" antes de cargar la página e intenta cerrar los banners de consentimiento de cookies. Esto modifica la página, por lo que está desactivado por defecto; lo realizado se registra en forensic_log.json consent_dismissal.",
            "default": false
          },
          "dismiss_popups": {
            "type": "boolean",
            "description": "Cierra las ventanas emergentes superpuestas de boletines, promociones y similares mediante su propio control de cierre. El número de ventanas emergentes cerradas y los controles utilizados se registran en forensic_log.json page_modifications. Cada ventana emergente cerrada se registra en forensic_log.json page_modifications (popups_dismissed, popups_dismissed_controls); evidence.pdf indica cuántas se cerraron.",
            "default": false
          },
          "dark_mode": {
            "type": "boolean",
            "description": "Renderiza con el esquema de colores oscuro. Cambia el aspecto de la página; se registra en forensic_log.json page_modifications. Se declara en evidence.pdf y en forensic_log.json page_modifications.",
            "default": false
          },
          "block_ads": {
            "type": "boolean",
            "description": "Bloquea las solicitudes a hosts conocidos de publicidad y rastreo. Omite contenido; se registra en forensic_log.json page_modifications.",
            "default": false
          },
          "block_resources": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "image",
                "font",
                "media",
                "stylesheet"
              ]
            },
            "description": "Tipos de recursos que se bloquean. Omite contenido; se registra en forensic_log.json page_modifications."
          },
          "inject_css": {
            "type": "string",
            "description": "CSS añadido a la página antes de la captura. Modifica la página; se almacena literalmente en forensic_log.json page_modifications. Siempre se almacena literalmente en forensic_log.json page_modifications (injected_css) con injected_css_status `applied` o `failed` e injected_css_error. evidence.pdf indica que se inyectó CSS personalizado; no imprime el código.",
            "maxLength": 10000
          },
          "inject_js": {
            "type": "string",
            "description": "JavaScript que se ejecuta en la página antes de la captura. Modifica la página. Siempre se guarda literalmente en forensic_log.json page_modifications (injected_js) con injected_js_status e injected_js_error: `applied` (se ejecutó hasta el final), `invalid` (no se compiló, por ejemplo por un error de sintaxis, así que nunca se ejecutó ni modificó la página), `failed` (lanzó una excepción durante la ejecución, así que puede haber modificado la página antes de detenerse) o `timed_out` (la captura dejó de esperarlo tras 5 segundos; el script puede haber seguido ejecutándose y modificando la página). evidence.pdf indica el resultado y remite a forensic_log.json, y el certificado de procedencia incluye el código inyectado entre las modificaciones de la página salvo que fuera `invalid`; ninguno de los dos imprime el código.",
            "maxLength": 10000
          },
          "locale": {
            "type": "string",
            "description": "Configuración regional del navegador que se emulará, por ejemplo de o de-DE (establece el idioma del navegador y Accept-Language). Solo emulación: no se usa ningún proxy, por lo que el contenido que depende de la IP del visitante sigue reflejando la región del servidor de captura. Se registra en metadata.json geo.",
            "pattern": "^[a-z]{2}(-[A-Z]{2})?$"
          },
          "timezone": {
            "type": "string",
            "description": "Zona horaria del navegador que se emulará (nombre IANA, por ejemplo Europe/Berlin). Solo emulación."
          },
          "language": {
            "type": "string",
            "description": "Idioma de los PDF legibles por humanos. Uno de: en, de, it, fr, es, pt_BR, cs, el, hi, hu, id, ja, ko, nl, pl, ro, ru, sv, tr, vi, zh_CN, zh_TW, ar, he, da, fi, nb, sk, uk. Se toleran mayúsculas, guion y región (se aceptan \"DE\", \"fr-FR\" y \"PT-BR\"); se rechazan \"pt\" y \"zh\" solos. Un valor no admitido devuelve 400 MALFORMED_REQUEST.",
            "default": "en",
            "example": "de"
          },
          "timestamp_type": {
            "type": "string",
            "description": "Solo se acepta \"bitcoin\" (siempre se intenta el anclaje en Bitcoin de OpenTimestamps). Cualquier otro valor devuelve 400 INVALID_TIMESTAMP_TYPE. Usa include_eidas para un sello cualificado de tiempo electrónico.",
            "enum": [
              "bitcoin"
            ],
            "default": "bitcoin"
          },
          "include_eidas": {
            "type": "boolean",
            "description": "Solicita además un sello cualificado de tiempo electrónico eIDAS (RFC 3161) sobre el SHA-256 de manifest.json, entregado como manifest.json.tsr (más eidas_validation.json cuando se devuelven datos de validación). Solo claves de producción: una clave de prueba devuelve 403 EIDAS_REQUIRES_LIVE_KEY. Si el despliegue no tiene configurado ningún servicio eIDAS, la solicitud se rechaza con 503 EIDAS_UNAVAILABLE antes de facturar nada. Antes de usar un token, ProofSnap lo comprueba (firma, el hash que contiene, la cadena de certificados hasta la raíz CA Disig QCA4, el estado del certificado de la TSA) y registra esa comprobación en eidas_validation.json (verification). Si la solicitud del sello de tiempo falla durante la captura, o el token no supera esa comprobación, el paquete se entrega sin él, solo con el anclaje de OpenTimestamps, el sello no se factura y timestamps.eidas (fila de la captura, GET /captures/{capture_id}, webhook completed) indica el estado not_obtained con el motivo (token_refused, unavailable o time_budget). A partir de la captura de pantalla, los pasos opcionales de la captura (los cuerpos WARC, las respuestas del archivo y de WHOIS, la firma C2PA, la segunda lectura del reloj) dejan tiempo para el sello y son los primeros en recortarse. Se factura por sello.",
            "default": false
          },
          "include_rule_902": {
            "type": "boolean",
            "description": "Añade rule_902_certification.pdf: una plantilla de certificación sin firmar según [FRE 902(13)/(14)](https://www.govinfo.gov/content/pkg/USCODE-2023-title28/html/USCODE-2023-title28-app-federalru-dup2-rule902.htm) y [28 U.S.C. 1746](https://www.govinfo.gov/content/pkg/USCODE-2023-title28/html/USCODE-2023-title28-partV-chap115-sec1746.htm) para que una persona la complete y la firme. Su SHA-256 figura en manifest.json. También se añade cuando evidence_pack.custodian tiene un valor no vacío. Si su generación falla, la captura se completa sin ella. Desactivado actualmente: la plantilla de certificación está desactivada de forma temporal, por lo que este campo se acepta y se ignora (no se genera ni se incluye en el paquete ningún rule_902_certification.pdf) y la respuesta incluye la advertencia RULE902_DISABLED.",
            "default": false
          },
          "case_reference": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PlainText"
              }
            ],
            "description": "Tu etiqueta de asunto o caso. Los espacios en blanco se compactan. Sigue la regla de texto plano (consulta PlainText). Se registra en metadata.json, forensic_log.json y chain_of_custody.json, y se imprime en evidence.pdf.",
            "maxLength": 200
          },
          "case_id": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PlainText"
              }
            ],
            "description": "El identificador del caso en tu sistema. Se registra igual que case_reference; sigue la regla de texto plano (consulta PlainText).",
            "maxLength": 200
          },
          "case_note": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PlainText"
              }
            ],
            "description": "Nota de texto libre. Sigue la regla de texto plano (consulta PlainText). Se registra en metadata.json, forensic_log.json y chain_of_custody.json (legal_context.note).",
            "maxLength": 500
          },
          "webhook_url": {
            "type": "string",
            "description": "URL https que recibe capture.completed o capture.failed. Consulta Webhooks. La URL debe ser https y no debe apuntar a una dirección privada, de loopback, link-local, de NAT de nivel de operador ni de metadatos de la nube (las mismas reglas que la URL de una captura; si no, 422 SSRF_BLOCKED). Un valor vacío o solo con espacios significa sin webhook. Requiere una clave de API: si se envía desde una sesión del panel se rechaza con 400 WEBHOOK_REQUIRES_API_KEY, porque los webhooks se firman con el secreto de firma del webhook de la clave.",
            "format": "uri"
          },
          "metadata": {
            "type": "object",
            "additionalProperties": {
              "type": "string",
              "maxLength": 500
            },
            "maxProperties": 10,
            "description": "Hasta 10 etiquetas de tipo cadena (valores de hasta 500 caracteres), devueltas con la captura. Los incumplimientos devuelven 400 METADATA_TOO_LARGE."
          },
          "evidence_pack": {
            "type": "object",
            "properties": {
              "enabled": {
                "type": "boolean",
                "description": "false deja la página de certificación fuera de evidence.pdf.",
                "default": true
              },
              "jurisdiction": {
                "type": "string",
                "description": "Se registra en forensic_log.json options. No tiene ningún otro efecto.",
                "enum": [
                  "US-Federal",
                  "EU-eIDAS"
                ],
                "default": "US-Federal"
              },
              "custodian": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Se acepta; no se imprime en la plantilla, cuyos campos del declarante quedan en blanco para la persona que la firma.",
                    "maxLength": 200
                  },
                  "law_firm": {
                    "type": "string",
                    "description": "Se acepta; no se imprime en la plantilla, cuyos campos del declarante quedan en blanco para la persona que la firma.",
                    "maxLength": 200
                  },
                  "role": {
                    "type": "string",
                    "description": "Se acepta; no se usa en la plantilla.",
                    "maxLength": 200
                  },
                  "case_caption": {
                    "type": "string",
                    "description": "Se acepta; no se usa en la plantilla.",
                    "maxLength": 200
                  }
                },
                "additionalProperties": false,
                "description": "Cada valor sigue la regla de texto plano (consulta PlainText); en caso contrario, 400 INVALID_EVIDENCE_PACK_OPTIONS."
              }
            },
            "description": "Los valores no válidos devuelven 400 INVALID_EVIDENCE_PACK_OPTIONS."
          }
        }
      },
      "CaptureRequest": {
        "type": "object",
        "required": [
          "url"
        ],
        "properties": {
          "url": {
            "type": "string",
            "description": "Página que se va a capturar. Solo http o https, como máximo 2048 caracteres. Las direcciones privadas, de loopback, link-local y de metadatos se rechazan con 422 SSRF_BLOCKED, y un nombre de host sin registros DNS devuelve 422 TARGET_UNREACHABLE.",
            "format": "uri",
            "maxLength": 2048
          },
          "options": {
            "$ref": "#/components/schemas/CaptureOptions"
          },
          "private_note": {
            "$ref": "#/components/schemas/PrivateNote"
          }
        },
        "example": {
          "url": "https://example.com",
          "options": {
            "case_reference": "Smith v. Jones",
            "webhook_url": "https://your-app.example/webhooks/proofsnap"
          }
        }
      },
      "CaptureAccepted": {
        "type": "object",
        "properties": {
          "capture_id": {
            "type": "string",
            "example": "cap_a1B2c3D4e5F6g7H8"
          },
          "status": {
            "type": "string",
            "enum": [
              "processing"
            ]
          },
          "url": {
            "type": "string"
          },
          "poll_path": {
            "type": "string",
            "description": "Ruta de estado que incluye el stage /v1, por ejemplo /v1/captures/cap_..."
          },
          "message": {
            "type": "string"
          },
          "metadata": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          },
          "private_note": {
            "type": "string",
            "description": "La nota privada tal como se almacenó (normalizada), presente solo cuando se envió una."
          },
          "warnings": {
            "$ref": "#/components/schemas/Rule902DisabledWarnings"
          }
        }
      },
      "CaptureResult": {
        "type": "object",
        "description": "Una captura, un certificado, una comparación o una ocultación. Los campos dependen del estado.",
        "properties": {
          "capture_id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "processing",
              "completed",
              "failed"
            ]
          },
          "url": {
            "type": "string",
            "description": "URL capturada (null para certificados y comparaciones).",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "batch_id": {
            "type": "string",
            "nullable": true
          },
          "metadata": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          },
          "type": {
            "type": "string",
            "description": "comparison en las comparaciones, import en un paquete de pruebas importado.",
            "enum": [
              "comparison",
              "import"
            ]
          },
          "compared": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Solo en comparaciones: los ID de las dos capturas de origen."
          },
          "diff_summary": {
            "$ref": "#/components/schemas/DiffSummary"
          },
          "estimated_completion_seconds": {
            "type": "integer",
            "description": "Solo processing: estimación aproximada, al menos 5."
          },
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string",
                "enum": [
                  "CAPTURE_FAILED",
                  "CAPTURE_TIME_BUDGET_EXCEEDED",
                  "CAPTURE_SCREENSHOT_TIMEOUT",
                  "SSRF_BLOCKED",
                  "TARGET_UNREACHABLE",
                  "PAYMENT_REQUIRED",
                  "COMPARE_PROFILE_MISMATCH",
                  "COMPARE_URL_MISMATCH",
                  "RULE902_NOT_AVAILABLE_FOR_MISMATCH",
                  "COMPARE_SOURCE_INTEGRITY_FAILED",
                  "QUOTA_EXCEEDED",
                  "CERTIFY_FAILED",
                  "REDACTION_FAILED"
                ],
                "description": "El motivo público del fallo (consulta la tabla Error). CAPTURE_SCREENSHOT_TIMEOUT: el navegador no terminó la captura de pantalla; CAPTURE_TIME_BUDGET_EXCEEDED: la ejecución agotó todo su presupuesto de tiempo; ninguno de los dos se cobra. Una fila de comparación (cmp_) también puede fallar con COMPARE_SOURCE_INTEGRITY_FAILED (un paquete de origen alterado después de aceptarse la solicitud), COMPARE_PROFILE_MISMATCH o COMPARE_URL_MISMATCH (detectados solo en los registros sellados que compara la ejecución) o RULE902_NOT_AVAILABLE_FOR_MISMATCH; la unidad se devuelve. Una certificación de hash ejecutada en segundo plano falla con QUOTA_EXCEEDED cuando, en el momento de ejecutarse, se ha alcanzado el máximo mensual. CERTIFY_FAILED y REDACTION_FAILED: no se pudo completar una certificación de hash o una ocultación; vuelve a intentar la solicitud."
              },
              "message": {
                "type": "string"
              }
            },
            "description": "Solo failed."
          },
          "failed_at": {
            "type": "string",
            "description": "Solo failed.",
            "format": "date-time"
          },
          "captured_at": {
            "type": "string",
            "description": "Cuándo se capturó la página (solo filas completadas). En una ocultación, la hora de captura de su captura de origen (redacted_at es cuándo se hizo la ocultación).",
            "format": "date-time"
          },
          "redacted_at": {
            "type": "string",
            "format": "date-time",
            "description": "Solo ocultaciones: cuándo se realizó la ocultación. El captured_at de una ocultación es la hora de captura de su captura de origen."
          },
          "compared_at": {
            "type": "string",
            "nullable": true,
            "format": "date-time",
            "description": "Solo comparaciones: cuándo se hizo la comparación. Su captured_at contiene el mismo valor (se mantiene para lectores más antiguos); usa compared_at."
          },
          "package_url": {
            "type": "string",
            "description": "Solo completed: enlace de descarga prefirmado del ZIP del paquete, válido durante un máximo de 24 horas (consulta package_url_expires_at); vuelve a leer la captura para obtener un enlace nuevo.",
            "format": "uri"
          },
          "package_url_expires_at": {
            "type": "string",
            "description": "Solo completed: cuándo deja de funcionar package_url, como máximo 24 horas después de esta respuesta (un enlace firmado con credenciales temporales del servidor puede dejar de funcionar antes); vuelve a leer la captura para obtener un enlace nuevo.",
            "format": "date-time"
          },
          "verification_url": {
            "type": "string",
            "description": "Solo completed: enlace al verificador público de ProofSnap.",
            "format": "uri"
          },
          "manifest_hash": {
            "type": "string",
            "description": "Solo completed: sha256:<hex> de manifest.json.",
            "example": "sha256:3f2a..."
          },
          "timestamps": {
            "$ref": "#/components/schemas/Timestamps"
          },
          "c2pa": {
            "type": "object",
            "properties": {
              "included": {
                "type": "boolean"
              },
              "signed_screenshot_sha256": {
                "type": "string",
                "description": "sha256:<hex> de la captura de pantalla firmada.",
                "nullable": true
              }
            },
            "description": "Solo completed."
          },
          "file_count": {
            "type": "integer",
            "description": "Solo completed."
          },
          "size_bytes": {
            "type": "integer",
            "description": "Solo completed."
          },
          "warnings": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "code": {
                  "type": "string",
                  "enum": [
                    "consent_gated"
                  ]
                },
                "reason": {
                  "type": "string",
                  "nullable": true
                },
                "final_host": {
                  "type": "string",
                  "nullable": true
                },
                "final_url": {
                  "type": "string",
                  "nullable": true
                },
                "message": {
                  "type": "string"
                },
                "remedy": {
                  "type": "object",
                  "properties": {
                    "option": {
                      "type": "string"
                    },
                    "value": {
                      "type": "boolean"
                    }
                  }
                }
              }
            },
            "description": "Solo completed, cuando la captura acabó en un muro de consentimiento de cookies o en una página intersticial en lugar de la página. Vuelve a ejecutarla con dismiss_consent: true."
          },
          "external_archive": {
            "allOf": [
              {
                "$ref": "#/components/schemas/ExternalArchive"
              }
            ],
            "description": "Solo completed. La solicitud a Internet Archive hecha con la opción external_archive: el resultado actual (status), el estado sellado en el paquete (status_at_sealing) y el enlace a la instantánea. { requested: false } cuando no se hizo ninguna solicitud."
          },
          "storage": {
            "$ref": "#/components/schemas/CaptureStorage"
          },
          "private_note": {
            "type": "string",
            "nullable": true,
            "description": "Tu nota privada (consulta PrivateNote). Solo se devuelve a quien hizo la captura; nunca forma parte del paquete de pruebas."
          },
          "legal_hold": {
            "type": "boolean",
            "description": "La captura está bajo retención legal: no se elimina mientras sea true (PATCH /captures/{capture_id} con action legal_hold). Se libera automáticamente, y la captura se elimina, cuando la suscripción ha finalizado y han pasado sus 30 días sin un plan que conserve los datos."
          },
          "legal_hold_set_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Cuándo se aplicó la retención legal actual; null sin retención."
          },
          "legal_hold_reason": {
            "type": "string",
            "nullable": true,
            "description": "El motivo almacenado con la retención legal actual; null sin retención o sin motivo."
          },
          "legal_hold_set_by_email": {
            "type": "string",
            "nullable": true,
            "description": "Solo el propietario de la empresa, en una captura de la empresa retenida: la dirección de correo electrónico de quien aplicó la retención."
          },
          "legal_hold_source": {
            "type": "string",
            "nullable": true,
            "description": "De dónde procede la retención actual: \"manual\" (PATCH /captures/{capture_id}), \"bulk\" (POST /legal-holds), \"matter:<matter_id>\" (la retención legal del asunto) o \"derived:<capture_id>\" (una ocultación o comparación retenida porque lo está una captura a partir de la cual se hizo). null sin retención."
          },
          "legal_hold_released_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Cuándo se liberó la última retención (inicio del periodo de gracia tras la liberación); null mientras está retenida o si nunca se liberó, y después de la liberación automática al final de una suscripción (sin periodo de gracia)."
          },
          "deletion_blocked_until": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Fin del periodo de gracia tras una liberación: hasta entonces, la captura no se elimina de forma permanente (DELETE permanente 409 CAPTURE_HOLD_GRACE, sin purga de la papelera, sin eliminación por conservación); se puede mover a la papelera. La eliminación de la cuenta y de la empresa tras el fin de la suscripción no lo espera. null cuando no hay ningún periodo de gracia en curso (también mientras hay una retención activa: esta bloquea sin fecha de fin)."
          },
          "s3_legal_hold": {
            "type": "object",
            "nullable": true,
            "description": "La retención legal S3 Object Lock de los objetos del paquete almacenado (ZIP, prueba de OpenTimestamps actualizada y el duplicado almacenado por separado, todas las versiones). status: on, off, pending (aplicándose), failed (el servidor lo reintenta), unsupported (el almacenamiento no tiene configuración de Object Lock; la retención en el registro de la captura sigue aplicándose), none (no hay paquete almacenado, por ejemplo una captura fallida).",
            "properties": {
              "status": {
                "type": "string",
                "enum": [
                  "on",
                  "off",
                  "pending",
                  "failed",
                  "unsupported",
                  "none"
                ]
              },
              "at": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              }
            }
          },
          "source_held": {
            "type": "boolean",
            "nullable": true,
            "description": "Ocultaciones y comparaciones: true cuando una captura a partir de la cual se realizaron está bajo retención legal. Un paquete derivado sigue la retención de su origen: mientras un origen está retenido, la ocultación o la comparación también lo está (legal_hold_source derived:<capture_id>) y su retención no se puede levantar por separado (409 LEGAL_HOLD_SOURCE). null para las demás capturas."
          },
          "import": {
            "$ref": "#/components/schemas/CaptureImport"
          },
          "source": {
            "type": "string",
            "description": "Cómo se hizo la captura: api, schedule, certify-hash, compare, redact, import. GET /captures/{capture_id} lo devuelve solo para un paquete importado (\"import\"); las filas de GET /captures siempre lo llevan."
          }
        }
      },
      "Timestamps": {
        "type": "object",
        "properties": {
          "bitcoin_ots": {
            "type": "object",
            "properties": {
              "status": {
                "type": "string",
                "description": "Estado de la prueba de OpenTimestamps en Bitcoin en manifest.json.ots. pending_confirmation: la prueba está esperando un bloque de Bitcoin (normalmente unas horas; las comprobaciones empiezan unas 2 horas después de completarse la captura y se repiten a intervalos crecientes). verified: un bloque de Bitcoin certifica el hash del manifiesto (block_height, block_time). not_present: el paquete no tiene manifest.json.ots (no se pudo contactar con los calendarios cuando se selló el paquete). unconfirmed: no se encontró ninguna confirmación en 7 días de comprobaciones; la prueba del paquete aún se puede actualizar y verificar con el cliente de OpenTimestamps (ots upgrade, ots verify). package_missing: el paquete de pruebas ya no está en el almacenamiento, por lo que la prueba ya no se puede comprobar. manifest_mismatch: el manifest.json del paquete almacenado no coincide con el hash del manifiesto registrado al sellarlo; el sello de tiempo no se confirma para él. Ambos son definitivos. El paquete en sí nunca se modifica. not_checked: la captura se completó antes de que existieran las comprobaciones automáticas de confirmación y nunca se comprobó; su paquete puede llevar aun así un manifest.json.ots que el cliente de OpenTimestamps puede actualizar y verificar. Las respuestas de webhook y de creación se envían al completarse, por lo que indican pending_confirmation (o not_present cuando fallaron los calendarios); lee GET /captures/{capture_id} para conocer el estado actual. Un paquete se sella con la prueba pendiente: en ese momento el hash del manifiesto ya se ha enviado a los calendarios de OpenTimestamps. Una vez confirmada en el bloque de Bitcoin {block_height} ({block_time} UTC), la prueba demuestra que manifest.json existía a más tardar en ese momento; descarga la prueba actualizada (upgraded_proof_url) y colócala junto a manifest.json como manifest.json.upgraded.ots, y verify.sh y verify.ps1 la comprueban automáticamente.",
                "enum": [
                  "pending_confirmation",
                  "verified",
                  "not_present",
                  "unconfirmed",
                  "package_missing",
                  "manifest_mismatch",
                  "not_checked"
                ]
              },
              "block_height": {
                "type": "integer",
                "nullable": true,
                "description": "Altura del bloque de Bitcoin que acredita la prueba. Se rellena cuando status es verified."
              },
              "block_time": {
                "type": "string",
                "format": "date-time",
                "nullable": true,
                "description": "Hora de ese bloque de Bitcoin. Solo en GET /captures/{capture_id}, se establece cuando status es verified."
              },
              "verified_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true,
                "description": "Cuándo confirmó ProofSnap el anclaje. Solo en GET /captures/{capture_id}, se establece cuando status es verified."
              },
              "upgraded_proof_url": {
                "type": "string",
                "format": "uri",
                "nullable": true,
                "description": "Solo GET /captures/{capture_id}. Una vez verificado el anclaje, ProofSnap guarda la prueba de OpenTimestamps actualizada desde los calendarios (lleva la atestación del bloque de Bitcoin, por lo que se verifica sin conexión sin contactar con ningún calendario). Esto es una descarga prefirmada de ella, válida hasta 24 horas como package_url; vuelve a leer la captura para obtener un enlace nuevo. null cuando no existe (aún no verificada, o guardada más tarde por un backfill en segundo plano). El paquete sellado nunca se modifica: guarda el archivo junto al manifest.json del paquete como manifest.json.upgraded.ots. Los paquetes sellados desde el 2026-09-29 la verifican con sus scripts incluidos (verification/verify.sh y verify.ps1 la usan en lugar de manifest.json.ots); para un paquete más antiguo, ejecuta `ots verify manifest.json.upgraded.ots -f manifest.json`. Las exportaciones de asuntos la incluyen como captures/<capture_id>.upgraded.ots junto a captures/<capture_id>.zip. Se elimina junto con el paquete."
              }
            }
          },
          "eidas": {
            "$ref": "#/components/schemas/EidasTimestamp"
          }
        }
      },
      "DiffSummary": {
        "type": "object",
        "properties": {
          "text_insertions": {
            "type": "integer",
            "description": "Líneas añadidas (el mismo valor que text_lines_added; desde el 2026-10-14 el texto se cuenta en líneas, consulta text_unit). Las comparaciones anteriores cuentan caracteres. Obsoleto (2026-10-14): se mantiene para los lectores existentes, no se elimina; usa text_lines_added.",
            "deprecated": true
          },
          "text_deletions": {
            "type": "integer",
            "description": "Líneas eliminadas (el mismo valor que text_lines_removed; líneas desde el 2026-10-14, caracteres antes). Obsoleto (2026-10-14): se mantiene para los lectores existentes, no se elimina; usa text_lines_removed.",
            "deprecated": true
          },
          "text_unit": {
            "type": "string",
            "enum": [
              "line"
            ],
            "description": "La unidad de los recuentos de texto: line (cada línea de domtextcontent.txt con su salto de línea). Ausente en las comparaciones realizadas antes del 2026-10-14, que contaban caracteres."
          },
          "text_lines_added": {
            "type": "integer",
            "description": "Líneas añadidas (líneas del texto After en los tramos añadidos del diff por líneas)."
          },
          "text_lines_removed": {
            "type": "integer",
            "description": "Líneas eliminadas (líneas del texto Before en los tramos eliminados del diff por líneas)."
          },
          "text_lines_before": {
            "type": "integer",
            "description": "Líneas del texto Before."
          },
          "text_lines_after": {
            "type": "integer",
            "description": "Líneas del texto After."
          },
          "text_changed_percent": {
            "type": "number",
            "description": "100 x (líneas eliminadas + líneas añadidas) / (líneas antes + líneas después), con un decimal."
          },
          "text_diff_capped": {
            "type": "boolean",
            "description": "true cuando el diff por líneas alcanzó su presupuesto de trabajo y una parte de los textos se contó como sustituida en bloque (sobreestima, nunca oculta un cambio)."
          },
          "metadata_changes": {
            "type": "array",
            "items": {
              "type": "object"
            },
            "description": "{ key, before, after, class } por cada campo de metadata.json que difiere, exactamente estos: url, final_url, page_title, http_status, http_headers.content-type, http_headers.server, dns.connected_ip, resolved_ips (comparados como lista ordenada), tls.issuer, tls.valid_to, y los campos de perfil de dispositivo viewport_width, viewport_height, device_scale_factor, is_mobile y user_agent. class es content (url, final_url, page_title, http_status, http_headers.content-type: cuentan como cambio), infrastructure (http_headers.server, dns.connected_ip, resolved_ips, tls.issuer, tls.valid_to: qué servidor entregó la página, solo se indican) o device_profile (cómo renderizó la captura la página, solo se indica). Una comparación realizada antes del 2026-10-10 no tiene class."
          },
          "meta_content": {
            "type": "integer",
            "description": "Campos de metadatos de la clase content que difieren; cuentan para changed."
          },
          "meta_infra": {
            "type": "integer",
            "description": "Campos de metadatos de la clase infrastructure que difieren (software del servidor, IP conectada, IP resueltas, emisor y caducidad TLS). Una red de distribución de contenidos responde desde direcciones rotativas, por lo que dos cargas de una página sin cambios pueden diferir aquí; se indican a título informativo y nunca cuentan para changed."
          },
          "meta_device_profile": {
            "type": "integer",
            "description": "Campos del perfil de dispositivo que difieren (área visible, escala, emulación móvil, user agent); se indican a título informativo y nunca cuentan para changed."
          },
          "image_changes": {
            "type": "object",
            "description": "La comparación de imágenes, a partir del inventario de imágenes que selló cada captura de origen (image_inventory.json). status ran, o not_run con reason source_without_inventory (un origen capturado antes de que las capturas registraran uno), source_inventory_failed o source_inventory_unreadable; no se usa nada más en su lugar. Los resultados completos por imagen están en image_changes.json del paquete.",
            "properties": {
              "status": {
                "type": "string",
                "enum": [
                  "ran",
                  "not_run"
                ]
              },
              "reason": {
                "type": "string",
                "nullable": true,
                "enum": [
                  "source_without_inventory",
                  "source_inventory_failed",
                  "source_inventory_unreadable",
                  null
                ],
                "description": "Por qué no se compararon las imágenes: source_without_inventory (un origen hecho antes de que las capturas registraran un inventario de imágenes; las capturas nuevas lo registran), source_inventory_failed (no se pudo registrar el inventario cuando se hizo esa captura) o source_inventory_unreadable; null cuando la comparación se ejecutó."
              },
              "images_changed": {
                "type": "integer",
                "nullable": true,
                "description": "Imágenes propias del sitio (servidas desde el dominio registrable de la propia página o, desde el 2026-10-14, desde un dominio que la página declara como su propia og:image o icono, o que sirve la mayoría de las imágenes de la página en ambas capturas: image_changes.json site_image_hosts) sustituidas, añadidas o eliminadas, la parte de imágenes de changed; no se incluyen las imágenes desplazadas. null cuando la comparación no se ejecutó. Obsoleto (2026-10-14): se mantiene para los lectores existentes, no se elimina; usa site_images_changed.",
                "deprecated": true
              },
              "first_party": {
                "$ref": "#/components/schemas/ImageChangeCounts"
              },
              "third_party": {
                "$ref": "#/components/schemas/ImageChangeCounts"
              },
              "tracking_pixel": {
                "$ref": "#/components/schemas/ImageChangeCounts"
              },
              "inventories_truncated": {
                "type": "boolean",
                "description": "true cuando un inventario de origen alcanzó su límite de entradas; consulta inventory_cutoff."
              },
              "site_images_changed": {
                "type": "integer",
                "description": "Lo mismo que images_changed (contado)."
              },
              "other_images_changed": {
                "type": "integer",
                "description": "Imágenes de otros dominios y píxeles de seguimiento sustituidos, eliminados o añadidos (nunca se cuentan)."
              },
              "site_image_hosts": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "Dominios registrables que se consideran propios del sitio además del de la página (site_image_hosts de image_changes.json)."
              },
              "inventory_cutoff": {
                "type": "object",
                "nullable": true,
                "properties": {
                  "before_only_index_limit": {
                    "type": "integer",
                    "nullable": true,
                    "description": "Una imagen presente solo en Before es determinable (removed) cuando su índice en el inventario de Before es inferior a este valor; null: sin límite (el inventario de After está completo)."
                  },
                  "after_only_index_limit": {
                    "type": "integer",
                    "nullable": true,
                    "description": "Una imagen presente solo en After es determinable (added) cuando su índice en el inventario de After es inferior a este valor; null: sin límite (el inventario de Before está completo)."
                  }
                },
                "description": "Desde la segunda auditoría del 2026-10-14 (algoritmo 3 de image_changes.json), un objeto, decidido por el índice en el documento: cuando el OTRO inventario alcanzó su límite de entradas, una imagen encontrada en un solo lado solo se puede decidir si su índice es inferior al número de entradas del otro inventario menos un margen (el mayor entre 10 entradas y el 5 por ciento de ese número); en caso contrario es indeterminada (undetermined_reason beyond_inventory_limit). null cuando ningún inventario está truncado. Las comparaciones realizadas antes llevan un número (la posición relativa en el documento de la regla anterior)."
              }
            }
          },
          "changed": {
            "type": "boolean",
            "nullable": true,
            "description": "true cuando el texto visible difiere, un campo de metadatos de contenido difiere (meta_content) o una imagen propia del sitio se sustituyó, eliminó o añadió (image_changes.images_changed). Los campos de infraestructura y de perfil de dispositivo, las imágenes movidas (los mismos bytes en otra dirección), las imágenes de terceros y los píxeles de seguimiento se indican pero no lo activan. null (desde el 2026-10-14) cuando nada de lo que cuenta cambió de forma fiable pero el resultado del texto o de las imágenes es indeterminado (capturas renderizadas de forma distinta, allow_profile_mismatch). El paquete indica la regla en comparison_method.json counted_as_change. Obsoleto (2026-10-14): se mantiene para los lectores existentes, no se elimina; usa verdict (changed | unchanged | undetermined) y los campos por tipo text_verdict, images_verdict, metadata_verdict.",
            "deprecated": true
          },
          "text_verdict": {
            "type": "string",
            "enum": [
              "changed",
              "unchanged",
              "undetermined"
            ],
            "description": "undetermined cuando las dos capturas se renderizaron con un perfil de dispositivo o ajustes de captura distintos (allow_profile_mismatch)."
          },
          "images_verdict": {
            "type": "string",
            "enum": [
              "changed",
              "unchanged",
              "undetermined",
              "not_run"
            ]
          },
          "metadata_verdict": {
            "type": "string",
            "enum": [
              "changed",
              "unchanged"
            ],
            "description": "Procede de los campos de metadatos de la clase content (dirección, dirección final, título, estado HTTP, tipo de contenido)."
          },
          "verdict": {
            "type": "string",
            "enum": [
              "changed",
              "unchanged",
              "undetermined"
            ],
            "description": "El resultado global en palabras: changed cuando changed es true, undetermined cuando changed es null."
          },
          "undetermined_reason": {
            "type": "string",
            "nullable": true,
            "enum": [
              "render_profile_mismatch",
              "capture_settings_mismatch",
              null
            ]
          },
          "text_changes": {
            "type": "array",
            "items": {
              "type": "object"
            },
            "description": "Solo GET: hasta 60 fragmentos de texto modificados."
          },
          "change_regions": {
            "type": "array",
            "items": {
              "type": "object"
            },
            "description": "Solo GET: hasta 60 cambios de texto situados en las capturas de pantalla (a partir del text_layout.json de las capturas de origen), en píxeles de la captura de pantalla. Cada cambio lleva counterpart_y_px (su posición vertical en la otra captura de pantalla, trasladada desde el bloque de texto sin cambios más cercano) y report_region (el número de la zona de cambio en la que lo muestra evidence.pdf, como máximo 10 zonas, o null) como en change_regions.json."
          },
          "change_regions_info": {
            "type": "object",
            "nullable": true
          },
          "order_reversed": {
            "type": "boolean",
            "description": "Solo GET: true cuando la captura indicada como capture_id_1 (Before) se capturó después de la indicada como capture_id_2 (After), como order_reversed en compare.json. La comparación mantiene el orden indicado; el informe y el panel lo indican."
          },
          "render_profile_mismatch": {
            "type": "boolean",
            "description": "Solo GET: las dos capturas se renderizaron con un perfil de dispositivo distinto (compare.json)."
          },
          "capture_settings_mismatch": {
            "type": "boolean",
            "description": "Solo GET: las dos capturas se hicieron con ajustes de captura distintos (compare.json)."
          },
          "capture_settings_differing": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "setting": {
                  "type": "string"
                },
                "before": {},
                "after": {}
              }
            },
            "description": "Solo GET: los ajustes que difieren, { setting, before, after }."
          },
          "profile_mismatch_allowed": {
            "type": "boolean",
            "description": "Solo GET: la comparación se hizo con allow_profile_mismatch para capturas renderizadas de forma distinta. Obsoleto (2026-10-14): se mantiene para los lectores existentes, no se elimina; usa render_profile_mismatch y capture_settings_mismatch.",
            "deprecated": true
          },
          "url_mismatch": {
            "type": "boolean",
            "description": "Solo GET: las dos capturas no comparten ninguna dirección (comparación hecha con allow_url_mismatch)."
          }
        },
        "description": "Resumen de la comparación."
      },
      "ImageChangeCounts": {
        "type": "object",
        "properties": {
          "unchanged": {
            "type": "integer"
          },
          "replaced": {
            "type": "integer"
          },
          "moved": {
            "type": "integer"
          },
          "removed": {
            "type": "integer"
          },
          "added": {
            "type": "integer"
          },
          "undetermined": {
            "type": "integer"
          },
          "alt_changed": {
            "type": "integer"
          },
          "position_changed": {
            "type": "integer"
          },
          "undetermined_same_error": {
            "type": "integer",
            "description": "Pares indeterminados cuyos dos lados devolvieron el mismo estado de error HTTP (por ejemplo 404 en ambas capturas); incluidos en undetermined, contados en el informe y no listados."
          },
          "occurrences": {
            "type": "integer",
            "description": "Todos los resultados de esta parte (propia o de terceros): apariciones en la página."
          },
          "distinct": {
            "type": "object",
            "description": "El número de imágenes distintas entre los resultados, en total y por categoría (una imagen mostrada varias veces cuenta una vez): cada resultado corresponde al SHA-256 de su lado After, o de su lado Before cuando no tiene uno, o a la dirección de ese lado cuando no lleva SHA-256.",
            "properties": {
              "total": {
                "type": "integer"
              },
              "unchanged": {
                "type": "integer"
              },
              "replaced": {
                "type": "integer"
              },
              "moved": {
                "type": "integer"
              },
              "removed": {
                "type": "integer"
              },
              "added": {
                "type": "integer"
              },
              "undetermined": {
                "type": "integer"
              }
            }
          }
        },
        "description": "Recuentos por categoría, como apariciones en la página (una imagen mostrada tres veces cuenta tres veces): unchanged, replaced (misma dirección, bytes distintos), moved (dirección distinta, mismos bytes; se indica, pero no cuenta como cambio), removed, added, undetermined (una imagen sin SHA-256 en alguno de los dos lados, nunca contada como removed ni added); además, las imágenes emparejadas cuyo texto alternativo o posición entre las imágenes de la página cambió, los pares indeterminados con el mismo error HTTP en ambos lados y las imágenes distintas."
      },
      "BatchRequest": {
        "type": "object",
        "required": [
          "urls"
        ],
        "properties": {
          "urls": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uri",
              "maxLength": 2048
            },
            "minItems": 2,
            "maxItems": 50,
            "description": "De 2 a 50 URL, cada una validada como CaptureRequest.url. Los nombres de host no se comprueban en DNS cuando se acepta el lote; una URL que no supera la comprobación de DNS o de dirección falla más tarde con el estado failed (y capture.failed)."
          },
          "options": {
            "$ref": "#/components/schemas/CaptureOptions"
          },
          "private_note": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PrivateNote"
              }
            ],
            "description": "Una nota privada guardada en cada captura del lote. Tu nota privada sobre la captura (por ejemplo \"mobile capture\"), como máximo 200 caracteres, texto plano (la regla PlainText). Se guarda solo en el registro de la captura: NO forma parte del paquete de pruebas sellado (no está en el ZIP, el PDF, el manifiesto ni en ningún hash) y no tiene valor probatorio, por lo que puede cambiarse o borrarse más tarde con PATCH /v1/captures/{capture_id} sin afectar a la prueba. Solo la lee el autor de la captura. Omítela, o envía null o una cadena vacía, si no quieres nota."
          }
        }
      },
      "BatchResult": {
        "type": "object",
        "properties": {
          "batch_id": {
            "type": "string",
            "example": "batch_a1B2c3D4e5F6g7H8"
          },
          "count": {
            "type": "integer"
          },
          "captures": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "capture_id": {
                  "type": "string"
                },
                "url": {
                  "type": "string"
                },
                "status": {
                  "type": "string",
                  "description": "failed cuando no se pudo iniciar la captura.",
                  "enum": [
                    "processing",
                    "failed"
                  ]
                }
              }
            }
          },
          "not_admitted": {
            "type": "array",
            "description": "Solo cuando se alcanzó el límite de capturas simultáneas de la cuenta: las URL que no se iniciaron, en el orden enviado. No cuestan nada (ninguna unidad del límite de frecuencia ni mensual); envíalas de nuevo más tarde. No está presente cuando se iniciaron todas las URL.",
            "items": {
              "type": "object",
              "properties": {
                "url": {
                  "type": "string"
                },
                "code": {
                  "type": "string",
                  "description": "Por qué no se inició la URL.",
                  "enum": [
                    "CONCURRENT_CAPTURE_LIMIT"
                  ]
                }
              }
            }
          },
          "in_flight_limit": {
            "type": "integer",
            "description": "Solo junto con not_admitted: el número de capturas que la cuenta puede tener en ejecución al mismo tiempo (developer 10, startup 25, growth 100, legal_solo y legal_seats 25, custom 200, website_archive 25, sandbox 5)."
          },
          "warnings": {
            "$ref": "#/components/schemas/Rule902DisabledWarnings"
          }
        }
      },
      "VerifyResult": {
        "type": "object",
        "properties": {
          "verdict": {
            "type": "string",
            "nullable": true,
            "description": "match: firma válida y cada archivo listado presente con su SHA-256 registrado. modified: firma no válida o un archivo ausente o modificado. invalid: no se pudo comprobar el paquete. null solo para un paquete importado que esta vez no se volvió a comprobar (`rechecked: false`, con `reason` y la verificación registrada en la importación en `stored_verification`).",
            "enum": [
              "match",
              "modified",
              "invalid",
              null
            ]
          },
          "reason": {
            "type": "string",
            "description": "Solo invalid; para un paquete importado con `rechecked: false`, por qué no se volvió a comprobar (too_large_for_synchronous_check, time_budget, storage_error)."
          },
          "capture_id": {
            "type": "string"
          },
          "source": {
            "type": "string",
            "enum": [
              "s3"
            ]
          },
          "checks": {
            "type": "object",
            "properties": {
              "manifest_signature": {
                "type": "object",
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "valid",
                      "invalid"
                    ]
                  },
                  "algorithm": {
                    "type": "string",
                    "enum": [
                      "RSA-4096"
                    ]
                  }
                }
              },
              "declared_files": {
                "type": "object",
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "all_match",
                      "mismatch"
                    ]
                  },
                  "mismatched": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "missing": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              },
              "bitcoin_ots": {
                "type": "object",
                "properties": {
                  "status": {
                    "type": "string",
                    "description": "Solo la presencia de manifest.json.ots; la prueba no se comprueba aquí.",
                    "enum": [
                      "present",
                      "not_present"
                    ]
                  }
                }
              },
              "eidas_disig": {
                "type": "object",
                "properties": {
                  "status": {
                    "type": "string",
                    "description": "Presencia de manifest.json.tsr. El token en sí se comprueba en checks.eidas. missing: manifest.json registra eidas_requested (el paquete se emitió con el token), pero manifest.json.tsr no está en el paquete.",
                    "enum": [
                      "present",
                      "missing"
                    ]
                  }
                },
                "nullable": true,
                "description": "null cuando no hay manifest.json.tsr y manifest.json no registra eidas_requested."
              },
              "proofsnap_issuance": {
                "type": "object",
                "properties": {
                  "status": {
                    "type": "string",
                    "description": "issued_by_proofsnap: el hash del manifiesto está en el registro de emisión de ProofSnap. unverified: no lo está (los paquetes emitidos antes de que existiera el registro no figuran en él). unknown: la consulta falló.",
                    "enum": [
                      "issued_by_proofsnap",
                      "unverified",
                      "unknown"
                    ]
                  },
                  "manifest_sha256": {
                    "type": "string"
                  },
                  "issued_at": {
                    "type": "string",
                    "nullable": true,
                    "format": "date-time"
                  },
                  "type": {
                    "type": "string"
                  }
                }
              },
              "eidas": {
                "type": "object",
                "nullable": true,
                "description": "null cuando no hay manifest.json.tsr y manifest.json no registra eidas_requested. El token de sello de tiempo eIDAS tal como lo comprueba ProofSnap, a petición, a partir del paquete almacenado. Es una comprobación realizada por ProofSnap; el campo note indica cómo verificar el token de forma independiente.",
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "verified",
                      "verified_revocation_unknown",
                      "failed",
                      "unavailable",
                      "missing"
                    ],
                    "description": "missing: manifest.json registra eidas_requested: true, por lo que el paquete se emitió con un sello cualificado de tiempo, pero manifest.json.tsr no está en él (la respuesta lleva entonces el aviso EIDAS_TOKEN_MISSING). verified: se superaron todas las comprobaciones y se conoce el estado del certificado de la TSA registrado en la emisión (good, o revocado después de la hora del token por un motivo distinto del compromiso de la clave). verified_revocation_unknown: se superaron todas las comprobaciones ejecutadas, pero no se pudo establecer el estado del certificado de la TSA a partir de los datos registrados (revocation.status unknown o not_checked). failed: al menos una comprobación falló, consulta failures (la respuesta lleva entonces el aviso EIDAS_TOKEN_FAILED). unavailable: sin veredicto sobre el token (reason: not configured, timeout, input_rejected cuando el verificador rechazó la entrada, por ejemplo datos de validación demasiado grandes, inconclusive); no se deduce nada sobre el token."
                  },
                  "reason": {
                    "type": "string",
                    "description": "Solo unavailable: timeout, input_rejected, inconclusive o una explicación breve."
                  },
                  "checked_by": {
                    "type": "string",
                    "example": "ProofSnap API on request"
                  },
                  "checked_at": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "gen_time": {
                    "type": "string",
                    "format": "date-time",
                    "nullable": true,
                    "description": "La hora del token (TSTInfo genTime)."
                  },
                  "tsa_name": {
                    "type": "string",
                    "nullable": true,
                    "description": "Procede del certificado del firmante (nombre común y organización), nunca es un valor predeterminado."
                  },
                  "serial_number": {
                    "type": "string",
                    "nullable": true
                  },
                  "policy_oid": {
                    "type": "string",
                    "nullable": true
                  },
                  "hash_algorithm": {
                    "type": "string",
                    "nullable": true
                  },
                  "signature_algorithm": {
                    "type": "string"
                  },
                  "signing_certificate_form": {
                    "type": "string",
                    "enum": [
                      "ESSCertIDv2",
                      "ESSCertID"
                    ]
                  },
                  "signer_certificate": {
                    "type": "object",
                    "nullable": true,
                    "properties": {
                      "subject": {
                        "type": "string"
                      },
                      "issuer": {
                        "type": "string"
                      },
                      "serial_number": {
                        "type": "string"
                      },
                      "sha256": {
                        "type": "string"
                      },
                      "not_before": {
                        "type": "string",
                        "format": "date-time"
                      },
                      "not_after": {
                        "type": "string",
                        "format": "date-time"
                      }
                    }
                  },
                  "root": {
                    "type": "object",
                    "properties": {
                      "label": {
                        "type": "string",
                        "example": "CA Disig QCA4"
                      },
                      "sha256": {
                        "type": "string"
                      }
                    }
                  },
                  "chain_checked_at": {
                    "type": "string",
                    "format": "date-time",
                    "nullable": true,
                    "description": "El momento respecto al cual se comprobó la cadena: el genTime del token, de modo que un certificado que caducó después de emitirse el token no lo invalida."
                  },
                  "chain": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "subject": {
                          "type": "string"
                        },
                        "sha256": {
                          "type": "string"
                        }
                      }
                    }
                  },
                  "revocation": {
                    "type": "object",
                    "description": "Estado del certificado de la TSA según los datos registrados en la emisión (basis recorded_at_issuance), verificado mediante firma y que debía estar vigente en genTime. No se obtiene en tiempo real.",
                    "properties": {
                      "status": {
                        "type": "string",
                        "enum": [
                          "good",
                          "revoked",
                          "revoked_after_gen_time",
                          "unknown",
                          "not_checked"
                        ],
                        "description": "revoked_after_gen_time: revocado después de la hora del token por un motivo distinto del compromiso de la clave (el token sigue verificado). not_checked: no se registraron ni comprobaron datos de revocación."
                      },
                      "source": {
                        "type": "string",
                        "enum": [
                          "ocsp",
                          "crl"
                        ],
                        "nullable": true
                      },
                      "basis": {
                        "type": "string",
                        "enum": [
                          "recorded_at_issuance"
                        ]
                      },
                      "produced_at": {
                        "type": "string",
                        "format": "date-time"
                      },
                      "this_update": {
                        "type": "string",
                        "format": "date-time"
                      },
                      "next_update": {
                        "type": "string",
                        "format": "date-time",
                        "nullable": true
                      },
                      "reason": {
                        "type": "string",
                        "description": "Por qué el estado es desconocido."
                      }
                    }
                  },
                  "checks": {
                    "type": "object",
                    "description": "Cada comprobación: passed, failed, not_applicable (nonce: no se registró ninguno para un token almacenado), unknown (revocación sin datos utilizables) o not_checked (un fallo anterior detuvo la comprobación).",
                    "properties": {
                      "response_status": {
                        "type": "string",
                        "enum": [
                          "passed",
                          "failed",
                          "not_applicable",
                          "unknown",
                          "not_checked"
                        ]
                      },
                      "message_imprint": {
                        "type": "string",
                        "enum": [
                          "passed",
                          "failed",
                          "not_applicable",
                          "unknown",
                          "not_checked"
                        ]
                      },
                      "nonce": {
                        "type": "string",
                        "enum": [
                          "passed",
                          "failed",
                          "not_applicable",
                          "unknown",
                          "not_checked"
                        ]
                      },
                      "content_type": {
                        "type": "string",
                        "enum": [
                          "passed",
                          "failed",
                          "not_applicable",
                          "unknown",
                          "not_checked"
                        ]
                      },
                      "message_digest": {
                        "type": "string",
                        "enum": [
                          "passed",
                          "failed",
                          "not_applicable",
                          "unknown",
                          "not_checked"
                        ]
                      },
                      "signature": {
                        "type": "string",
                        "enum": [
                          "passed",
                          "failed",
                          "not_applicable",
                          "unknown",
                          "not_checked"
                        ]
                      },
                      "signing_certificate": {
                        "type": "string",
                        "enum": [
                          "passed",
                          "failed",
                          "not_applicable",
                          "unknown",
                          "not_checked"
                        ]
                      },
                      "timestamping_eku": {
                        "type": "string",
                        "enum": [
                          "passed",
                          "failed",
                          "not_applicable",
                          "unknown",
                          "not_checked"
                        ]
                      },
                      "chain": {
                        "type": "string",
                        "enum": [
                          "passed",
                          "failed",
                          "not_applicable",
                          "unknown",
                          "not_checked"
                        ]
                      },
                      "revocation": {
                        "type": "string",
                        "enum": [
                          "passed",
                          "failed",
                          "not_applicable",
                          "unknown",
                          "not_checked"
                        ]
                      }
                    }
                  },
                  "failures": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "check": {
                          "type": "string"
                        },
                        "reason": {
                          "type": "string"
                        }
                      }
                    }
                  },
                  "note": {
                    "type": "string"
                  },
                  "detail": {
                    "type": "string",
                    "nullable": true,
                    "description": "Solo input_rejected: qué rechazó el verificador."
                  }
                }
              }
            },
            "description": "Ausente cuando verdict es invalid. El veredicto depende solo de manifest_signature y declared_files; eidas se indica junto a él y, si falló, en warnings."
          },
          "warnings": {
            "type": "array",
            "description": "Presente solo cuando algo ajeno al veredicto requiere atención. EIDAS_TOKEN_FAILED: manifest.json.tsr no superó la comprobación de checks.eidas. EIDAS_TOKEN_MISSING: manifest.json registra eidas_requested, pero manifest.json.tsr no está en el paquete.",
            "items": {
              "type": "object",
              "properties": {
                "code": {
                  "type": "string",
                  "enum": [
                    "EIDAS_TOKEN_FAILED",
                    "EIDAS_TOKEN_MISSING"
                  ]
                },
                "message": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "ScheduleRequest": {
        "type": "object",
        "required": [
          "url",
          "mode"
        ],
        "properties": {
          "url": {
            "type": "string",
            "description": "Página que se va a capturar, validada como CaptureRequest.url.",
            "format": "uri",
            "maxLength": 2048
          },
          "mode": {
            "type": "string",
            "description": "interval: cada interval_seconds. cron: según cron_expr. on_change: comprueba la página cada interval_seconds y captura solo cuando su texto ha cambiado.",
            "enum": [
              "interval",
              "cron",
              "on_change"
            ]
          },
          "interval_seconds": {
            "type": "integer",
            "description": "Obligatorio para interval y on_change. Como mínimo el mínimo del plan (300 en developer, startup, growth, custom, legal_solo y legal_seats; 86400 en website_archive); de lo contrario, 400 INTERVAL_TOO_SHORT.",
            "minimum": 300
          },
          "cron_expr": {
            "type": "string",
            "description": "Obligatorio para cron (como máximo 200 caracteres). Una expresión que no se puede interpretar devuelve 400 INVALID_CRON; dos ejecuciones consecutivas más próximas que el mínimo del plan devuelven 400 INTERVAL_TOO_SHORT.",
            "maxLength": 200,
            "example": "0 9 * * 1-5"
          },
          "timezone": {
            "type": "string",
            "description": "Zona horaria IANA usada para evaluar cron_expr. Predeterminado: UTC.",
            "example": "Europe/Berlin"
          },
          "webhook_url": {
            "type": "string",
            "description": "URL https. Las ejecuciones interval y cron envían capture.completed o capture.failed; on_change envía monitor.change_detected. La URL debe ser https y no debe apuntar a una dirección privada, de loopback, link-local, de NAT de nivel de operador ni de metadatos de la nube (las mismas reglas que la URL de una captura; si no, 422 SSRF_BLOCKED). Un valor vacío o solo con espacios significa sin webhook. Una programación tiene un solo webhook_url, en el nivel superior: un options.webhook_url no se almacena (cuando no se envía ningún webhook_url de nivel superior, se usa como tal), por lo que una programación on_change nunca envía además capture.completed. Requiere una clave de API: si se envía desde una sesión del panel se rechaza con 400 WEBHOOK_REQUIRES_API_KEY, porque los webhooks se firman con el secreto de firma del webhook de la clave.",
            "format": "uri"
          },
          "js_render": {
            "type": "boolean",
            "description": "Solo on_change: comprueba la página con un renderizado completo en navegador en lugar de una petición HTTP simple (para páginas que necesitan JavaScript).",
            "default": false
          },
          "options": {
            "$ref": "#/components/schemas/CaptureOptions"
          },
          "max_capture_age_seconds": {
            "type": "integer",
            "nullable": true,
            "minimum": 86400,
            "maximum": 34560000,
            "description": "Solo on_change: realiza también una captura completa (que envía capture.completed) cuando la captura más reciente de la programación es más antigua que este valor, aunque la página no haya cambiado. Para normas que exigen una copia al menos cada N días, por ejemplo 7776000 para 90 días. De 1 a 400 días y más que interval_seconds; null (predeterminado) solo captura cuando hay un cambio. PATCH con null lo elimina."
          },
          "alert_on": {
            "type": "string",
            "enum": [
              "any",
              "text"
            ],
            "nullable": true,
            "description": "Solo on_change (2026-10-14): qué cambio detectado envía monitor.change_detected. any (predeterminado, el comportamiento anterior): cada captura por cambio. text: solo cuando la comparación con la captura anterior determina que el texto visible ha cambiado (diff_summary.text_verdict changed); una captura por cambio cuya comparación indica que el texto no cambió o que no se pudo determinar no envía nada (la captura se realiza igualmente, se factura y envía capture.completed a las suscripciones). Sin resultado de comparación (sin referencia, la comparación falló) envía la alerta. 400 MALFORMED_REQUEST con otro modo u otro valor. null para las programaciones interval y cron."
          }
        },
        "example": {
          "url": "https://example.com/terms",
          "mode": "on_change",
          "interval_seconds": 3600,
          "webhook_url": "https://your-app.example/webhooks/proofsnap"
        }
      },
      "Schedule": {
        "type": "object",
        "properties": {
          "schedule_id": {
            "type": "string"
          },
          "url": {
            "type": "string"
          },
          "mode": {
            "type": "string",
            "enum": [
              "interval",
              "cron",
              "on_change"
            ]
          },
          "interval_seconds": {
            "type": "integer",
            "nullable": true
          },
          "cron_expr": {
            "type": "string",
            "nullable": true
          },
          "timezone": {
            "type": "string",
            "nullable": true
          },
          "webhook_url": {
            "type": "string",
            "nullable": true,
            "description": "El webhook de la programación (en una programación más antigua que lo guardaba en options.webhook_url, ese valor se muestra aquí y options nunca lo contiene). Requiere una clave de API: si se envía desde una sesión del panel se rechaza con 400 WEBHOOK_REQUIRES_API_KEY, porque los webhooks se firman con el secreto de firma del webhook de la clave."
          },
          "options": {
            "type": "object",
            "description": "Opciones de captura con los valores predeterminados aplicados."
          },
          "js_render": {
            "type": "boolean"
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "paused"
            ]
          },
          "next_run_at": {
            "type": "string",
            "format": "date-time"
          },
          "last_run_at": {
            "type": "string",
            "nullable": true,
            "format": "date-time"
          },
          "last_capture_id": {
            "type": "string",
            "nullable": true
          },
          "run_count": {
            "type": "integer"
          },
          "consecutive_failures": {
            "type": "integer"
          },
          "pause_reason": {
            "type": "string",
            "description": "Por qué se pausó automáticamente la programación: max_failures (5 ejecuciones fallidas consecutivas), no_billing, plan_downgrade (límites del plan, o un plan Archive only), invalid_cron o key_revoked. null cuando está activa o la has pausado tú.",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "max_capture_age_seconds": {
            "type": "integer",
            "nullable": true
          },
          "current_capture_id": {
            "type": "string",
            "nullable": true,
            "description": "La captura COMPLETADA más reciente de la programación (su versión actual; last_capture_id es la más reciente lanzada)."
          },
          "current_captured_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "alert_on": {
            "type": "string",
            "enum": [
              "any",
              "text"
            ],
            "nullable": true,
            "description": "on_change: any o text (any para una programación creada antes de la opción); null para interval y cron."
          },
          "warnings": {
            "$ref": "#/components/schemas/Rule902DisabledWarnings"
          }
        }
      },
      "Matter": {
        "type": "object",
        "properties": {
          "matter_id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "client": {
            "type": "string",
            "nullable": true
          },
          "case_number": {
            "type": "string",
            "nullable": true
          },
          "opposing_party": {
            "type": "string",
            "nullable": true
          },
          "jurisdiction": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "enum": [
              "open",
              "archived",
              "sealed"
            ]
          },
          "sealed": {
            "type": "boolean"
          },
          "capture_count": {
            "type": "integer"
          },
          "custody_count": {
            "type": "integer",
            "description": "Entradas del registro de la cadena de custodia."
          },
          "custody_head_hash": {
            "type": "string",
            "description": "sha256:<hex> de la entrada más reciente del registro de cadena de custodia.",
            "nullable": true
          },
          "seal": {
            "$ref": "#/components/schemas/MatterSeal",
            "description": "null hasta que se sella."
          },
          "member_uids": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "created_by": {
            "type": "string",
            "description": "El propietario.",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "read_only": {
            "type": "boolean",
            "description": "Presente (true) en un asunto de una empresa que ha finalizado o se conserva como archivo de empresa: solo apertura, exportación y revocación del enlace compartido."
          },
          "legal_hold": {
            "type": "boolean",
            "description": "La retención legal del asunto está activada (PATCH { legal_hold })."
          },
          "legal_hold_set_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "MatterSeal": {
        "type": "object",
        "properties": {
          "root_hash": {
            "type": "string",
            "description": "sha256:<hex>: la cabeza de la cadena de custodia que se selló y se envió a OpenTimestamps."
          },
          "ots": {
            "type": "string",
            "description": "Prueba de OpenTimestamps, en hexadecimal."
          },
          "ots_pending": {
            "type": "boolean"
          },
          "eidas": {
            "type": "object",
            "properties": {
              "tsr_base64": {
                "type": "string",
                "nullable": true
              },
              "serial": {
                "type": "string",
                "nullable": true
              },
              "issued_at": {
                "type": "string",
                "nullable": true
              },
              "ltv": {
                "type": "boolean"
              },
              "verification_status": {
                "type": "string",
                "enum": [
                  "verified"
                ],
                "description": "El token superó la comprobación de ProofSnap en el sellado (verificador compartido, mismas comprobaciones que el token de una captura)."
              },
              "revocation_status": {
                "type": "string",
                "enum": [
                  "good",
                  "revoked_after_gen_time",
                  "unknown",
                  "not_checked"
                ],
                "description": "Estado del certificado de la TSA establecido en el sellado a partir de la respuesta OCSP o la CRL obtenidas en ese momento."
              },
              "validation": {
                "type": "object",
                "nullable": true,
                "description": "El contenido de eidas_validation.json registrado en el sellado, para comprobar el token offline: tsaCertChain, rootCaPem, ocspResponse, crlResponse (se omite y se indica en omitted cuando es demasiado grande), ocspUrl, ocspFetchedAt, ocspStatus, crlUrl, verification. Los sellos creados antes del 2026-10-01 no lo tienen.",
                "additionalProperties": true
              }
            },
            "nullable": true,
            "description": "Sello de tiempo eIDAS sobre el mismo hash cuando el despliegue tiene eIDAS configurado, quien llama está en producción y se obtuvo un token que superó la comprobación; si no, null (consulta eidas_status)."
          },
          "sealed_by": {
            "type": "string",
            "nullable": true
          },
          "sealed_at": {
            "type": "string",
            "format": "date-time"
          },
          "eidas_status": {
            "type": "string",
            "enum": [
              "confirmed",
              "not_obtained",
              "not_configured"
            ],
            "description": "Ausente en los sellos creados antes del 2026-10-01. not_obtained: el sello solo contiene el anclaje de OpenTimestamps (eidas_reason token_refused, unavailable o test_key). Si se agota el tiempo de espera del sello de tiempo eIDAS, nunca se produce un sello: en ese caso la solicitud devuelve 503 EIDAS_SEAL_RETRY."
          },
          "eidas_reason": {
            "type": "string",
            "enum": [
              "token_refused",
              "unavailable",
              "test_key",
              "account_limit"
            ],
            "description": "Solo not_obtained. test_key: sellado por quien llama en modo de prueba (clave de API de prueba o sesión de sandbox), por lo que no se solicitó ningún sello de tiempo eIDAS. account_limit: la cuenta aún no tiene ninguna factura pagada y ha alcanzado su límite eIDAS (20 por día UTC, 50 por mes UTC)."
          }
        },
        "nullable": true
      },
      "CustodyEntry": {
        "type": "object",
        "properties": {
          "seq": {
            "type": "integer"
          },
          "ts": {
            "type": "string",
            "format": "date-time"
          },
          "actor_uid": {
            "type": "string",
            "nullable": true
          },
          "actor_name": {
            "type": "string",
            "nullable": true
          },
          "actor_role": {
            "type": "string",
            "enum": [
              "api",
              "user"
            ],
            "nullable": true
          },
          "action": {
            "type": "string",
            "description": "Por ejemplo matter.created, matter.updated, capture.added, capture.removed, capture.legal_hold_placed, capture.legal_hold_released, member.added, member.removed, package.imported (un paquete de pruebas importado con matter_id; target es el ID de la captura)."
          },
          "target": {
            "type": "string",
            "nullable": true
          },
          "prev_hash": {
            "type": "string"
          },
          "entry_hash": {
            "type": "string"
          },
          "details": {
            "type": "object",
            "description": "Solo package.imported: manifest_sha256, zip_sha256, package_kind, verdict, levels. Se almacenan con la entrada, pero entry_hash NO los cubre.",
            "additionalProperties": true
          }
        },
        "description": "Un eslabón de la cadena de hash. entry_hash cubre seq, ts, actor, action, target y prev_hash."
      },
      "MatterCapture": {
        "type": "object",
        "properties": {
          "capture_id": {
            "type": "string"
          },
          "status": {
            "type": "string"
          },
          "url": {
            "type": "string",
            "nullable": true
          },
          "source": {
            "type": "string"
          },
          "label": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "string"
          },
          "captured_at": {
            "type": "string",
            "nullable": true
          },
          "file_count": {
            "type": "integer",
            "nullable": true
          },
          "manifest_hash": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "AuditEntry": {
        "type": "object",
        "properties": {
          "ts": {
            "type": "string"
          },
          "actor_uid": {
            "type": "string",
            "nullable": true
          },
          "actor_name": {
            "type": "string",
            "nullable": true
          },
          "actor_role": {
            "type": "string",
            "nullable": true
          },
          "action": {
            "type": "string",
            "description": "Por ejemplo capture.downloaded, export.generated, share.created, share.revoked."
          },
          "target": {
            "type": "string",
            "nullable": true
          },
          "source_ip": {
            "type": "string",
            "nullable": true,
            "description": "Solo el propietario del asunto (ausente para los miembros)."
          },
          "user_agent": {
            "type": "string",
            "nullable": true,
            "description": "Solo el propietario del asunto (ausente para los miembros)."
          }
        },
        "description": "Evento de acceso. No forma parte de la cadena de hashes."
      },
      "CertifyResult": {
        "type": "object",
        "properties": {
          "certify_id": {
            "type": "string",
            "example": "cert_a1B2c3D4e5F6g7H8"
          },
          "status": {
            "type": "string",
            "enum": [
              "completed"
            ]
          },
          "hash_sha256": {
            "type": "string",
            "description": "Solo certificado único."
          },
          "combined": {
            "type": "boolean",
            "description": "Solo certificado combinado."
          },
          "file_count": {
            "type": "integer",
            "description": "Solo certificado combinado."
          },
          "files": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "name": {
                  "type": "string",
                  "description": "La etiqueta que enviaste.",
                  "nullable": true
                },
                "sha256": {
                  "type": "string"
                }
              }
            },
            "description": "Solo certificado combinado."
          },
          "label": {
            "type": "string",
            "description": "Tu etiqueta, o \"N files\" para un certificado combinado.",
            "nullable": true
          },
          "certified_at": {
            "type": "string",
            "format": "date-time"
          },
          "timestamps": {
            "type": "object",
            "properties": {
              "bitcoin_ots": {
                "type": "object",
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "pending_confirmation",
                      "not_present"
                    ],
                    "description": "pending_confirmation: manifest.json.ots está en el paquete y espera un bloque de Bitcoin. not_present: no se pudo contactar con los calendarios de OpenTimestamps, el paquete no tiene manifest.json.ots y manifest.json no lo lista."
                  },
                  "block_height": {
                    "type": "integer",
                    "nullable": true
                  }
                }
              },
              "eidas": {
                "$ref": "#/components/schemas/EidasTimestamp"
              }
            }
          },
          "package_url": {
            "type": "string",
            "format": "uri"
          },
          "package_url_expires_at": {
            "type": "string",
            "format": "date-time"
          },
          "verify_url": {
            "type": "string",
            "description": "Enlace al verificador público de ProofSnap.",
            "format": "uri"
          },
          "warnings": {
            "$ref": "#/components/schemas/Rule902DisabledWarnings"
          }
        }
      },
      "PlainText": {
        "type": "string",
        "description": "Regla de texto sin formato para los campos breves de texto libre que se imprimen en documentos destinados al tribunal o en páginas públicas (case_reference, case_id, case_note, valores de evidence_pack.custodian, el case_reference de certify-hash, el custodio de la ocultación, la declaración de exportación, nombres de empresa y de asunto). El valor se normaliza en NFC, los espacios se contraen y se recortan, y la longitud se cuenta en caracteres (puntos de código Unicode). Se rechazan: caracteres de control (C0, C1), incrustaciones, anulaciones y aislamientos bidi (de U+202A a U+202E, de U+2066 a U+2069), marcas de dirección (U+200E, U+200F, U+061C), caracteres de ancho cero e invisibles (U+200B, de U+2060 a U+2064, U+FEFF, U+00AD, U+034F, U+180E, de U+206A a U+206F, de U+FFF9 a U+FFFB), los caracteres de relleno hangul (U+115F, U+1160, U+3164, U+FFA0), los caracteres de etiqueta (de U+E0000 a U+E007F), los sustitutos aislados y los caracteres de uso privado. Se aceptan las letras de todas las escrituras, incluidas las sílabas hangul, el árabe, el hebreo, el devanagari y los caracteres CJK, y U+200C / U+200D (ZWNJ, ZWJ) se admiten porque la ortografía correcta de varias escrituras los necesita."
      },
      "EidasTimestamp": {
        "type": "object",
        "description": "Resultado eIDAS cuando se solicitó include_eidas con una clave de producción; ausente cuando no se solicitó. confirmed: el paquete lleva manifest.json.tsr (se factura, salvo que revocation sea unknown). not_obtained: no se entregó ningún token y no se facturó nada; el paquete solo lleva el anclaje de OpenTimestamps (reason token_refused: se recibió un token que no superó la comprobación en la emisión; unavailable: no se pudo obtener ningún token; time_budget: la solicitud no cabía en el tiempo restante, así que no se hizo, el servicio de sellado de tiempo la rechazó antes de cualquier solicitud a la autoridad, o se detuvo, lo que ocurre en la ruta de ocultación síncrona o al final de una captura larga; account_limit: la cuenta aún no tiene ninguna factura pagada y ha alcanzado su límite eIDAS de 20 sellos por día UTC o 50 por mes UTC, contados conjuntamente entre capturas, lotes, programaciones, certificaciones, comparaciones, ocultaciones y sellos de asuntos, por lo que no se hizo ninguna solicitud; las cuentas con una factura pagada no tienen límite). Las filas completadas antes del 2026-10-01 solo indican confirmed.",
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "confirmed",
              "not_obtained"
            ]
          },
          "reason": {
            "type": "string",
            "enum": [
              "token_refused",
              "unavailable",
              "time_budget",
              "account_limit"
            ],
            "description": "Solo not_obtained."
          },
          "serial": {
            "type": "string",
            "nullable": true,
            "description": "Solo confirmed."
          },
          "issued_at": {
            "type": "string",
            "nullable": true,
            "description": "Solo confirmed."
          },
          "revocation": {
            "type": "string",
            "enum": [
              "unknown"
            ],
            "description": "Solo confirmed, y solo cuando no se pudo establecer el estado del certificado de la TSA en la emisión (el token superó todas las demás comprobaciones). El token se entrega y no se factura."
          },
          "billed": {
            "type": "boolean",
            "enum": [
              false
            ],
            "description": "Presente (false) junto con revocation unknown: este token no se facturó."
          }
        }
      },
      "ExternalArchive": {
        "type": "object",
        "description": "El resultado de Internet Archive (Wayback Machine) de una captura. Internet Archive es un archivo público de terceros.",
        "required": [
          "requested"
        ],
        "properties": {
          "requested": {
            "type": "boolean",
            "description": "false cuando no se hizo ninguna solicitud a Internet Archive (external_archive: false, una certificación o comparación, una captura hecha antes de que existiera el archivado, o la solicitud no cabía en el presupuesto de tiempo). En ese caso, todos los demás campos están ausentes."
          },
          "status": {
            "type": "string",
            "enum": [
              "created",
              "recent_existing",
              "created_after_request",
              "existing",
              "pending",
              "rate_limited",
              "failed",
              "unconfirmed",
              "unavailable",
              "skipped",
              "skipped_sensitive",
              "skipped_query"
            ],
            "description": "Resultado actual. Para un trabajo de Save Page Now que sigue pendiente en el momento del sellado, el programador lee el estado del trabajo unos 5 y 30 minutos después de la solicitud y este valor pasa a ser el resultado final (created, recent_existing, failed, rate_limited, o unconfirmed cuando no llegó ninguna respuesta dentro de la ventana de comprobación). created, recent_existing, created_after_request y existing incluyen snapshot_url."
          },
          "status_at_sealing": {
            "type": "string",
            "enum": [
              "created",
              "recent_existing",
              "created_after_request",
              "existing",
              "pending",
              "rate_limited",
              "failed",
              "unconfirmed",
              "unavailable",
              "skipped",
              "skipped_sensitive",
              "skipped_query"
            ],
            "description": "El estado sellado en el paquete (lo registran metadata.json, forensic_log.json, chain_of_custody.json y evidence.pdf). El paquete en sí nunca cambia; coincide con status salvo que un seguimiento haya resuelto un trabajo pendiente."
          },
          "method": {
            "type": "string",
            "nullable": true,
            "enum": [
              "spn2_authenticated",
              "spn_anonymous",
              null
            ]
          },
          "requested_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "snapshot_url": {
            "type": "string",
            "format": "uri",
            "nullable": true,
            "description": "La copia pública de Wayback Machine, siempre https://web.archive.org/web/{snapshot_timestamp}/{url}."
          },
          "snapshot_timestamp": {
            "type": "string",
            "nullable": true,
            "description": "Marca de tiempo de Wayback YYYYMMDDhhmmss (UTC)."
          },
          "snapshot_offset_seconds": {
            "type": "integer",
            "nullable": true,
            "description": "Hora de la instantánea menos la hora de la captura, en segundos (negativo: antes de la captura)."
          },
          "resolved_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Cuándo registró el seguimiento del programador el resultado definitivo; null cuando el estado sellado ya era definitivo."
          },
          "job_id": {
            "type": "string",
            "nullable": true,
            "description": "ID del trabajo de Save Page Now (solicitudes autenticadas)."
          },
          "follow_up_pending": {
            "type": "boolean",
            "description": "true mientras el programador aún tiene que leer el estado del trabajo."
          },
          "skip_reason": {
            "type": "string",
            "nullable": true,
            "description": "Por qué no se envió la URL, cuando status es skipped, skipped_sensitive o skipped_query: credentials_in_url, sensitive_path, opaque_path_segment, sensitive_fragment, sensitive_parameter_name, opaque_parameter_value, query_string (o el propio estado en filas más antiguas)."
          },
          "resolved": {
            "type": "object",
            "nullable": true,
            "properties": {
              "status": {
                "type": "string",
                "enum": [
                  "created",
                  "recent_existing",
                  "failed",
                  "rate_limited",
                  "unresolved"
                ]
              },
              "job_status": {
                "type": "string",
                "nullable": true,
                "description": "success, error o pending tal como se leyeron del trabajo de Save Page Now, null cuando no se pudo leer."
              },
              "snapshot_url": {
                "type": "string",
                "nullable": true,
                "description": "https://web.archive.org/web/<14 digits>/<URL>, null cuando no existe o no es válida."
              },
              "snapshot_timestamp": {
                "type": "string",
                "nullable": true
              },
              "checked_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              },
              "reason": {
                "type": "string",
                "nullable": true,
                "enum": [
                  "no_request_time",
                  "capture_deleted",
                  "capture_failed",
                  null
                ],
                "description": "Por qué no hay respuesta del trabajo: no_request_time (la fila no tiene hora de solicitud a partir de la cual programar las lecturas), capture_deleted o capture_failed (la captura se envió a la papelera o falló mientras el trabajo estaba pendiente, por lo que no se hizo seguimiento; una captura restaurada de la papelera en los 30 minutos siguientes a la solicitud vuelve a tener seguimiento). null en otro caso."
              }
            },
            "description": "El registro de seguimiento (historial), como proyección fija: snapshot_url solo cuando es una URL de instantánea de Wayback válida de snapshot_timestamp; reason solo cuando el seguimiento terminó sin respuesta del trabajo."
          }
        }
      },
      "CaptureStorage": {
        "type": "object",
        "description": "Conservación, la copia duplicada y (capturas automáticas) el historial de versiones.",
        "properties": {
          "retention": {
            "type": "object",
            "properties": {
              "years": {
                "type": "integer",
                "example": 10
              },
              "counted_from": {
                "type": "string",
                "format": "date-time",
                "nullable": true,
                "description": "El más reciente entre created_at y el último uso conocido: la última comprobación que encontró la página sin cambios, o el momento en que una captura más reciente de la misma programación sustituyó a esta."
              },
              "earliest_deletion_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true,
                "description": "counted_from más years, aproximado: la eliminación se ejecuta en un barrido posterior del programador, y comprobaciones posteriores pueden moverla. null bajo retención legal."
              },
              "legal_hold": {
                "type": "boolean"
              }
            }
          },
          "duplicate_copy": {
            "type": "object",
            "properties": {
              "status": {
                "type": "string",
                "enum": [
                  "none",
                  "pending",
                  "stored",
                  "not_copied"
                ],
                "description": "stored: se conserva una segunda copia del paquete (y de su prueba OTS actualizada) en un bucket independiente en otra región de la UE, y se elimina junto con el original. pending: copiándose (minutos). none: paquetes de prueba y paquetes anteriores a la existencia del duplicado, hasta que se copian."
              },
              "copied_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              }
            }
          },
          "version": {
            "type": "object",
            "description": "Solo capturas automáticas.",
            "properties": {
              "superseded_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              },
              "superseded_by": {
                "type": "string",
                "nullable": true
              },
              "unchanged_first_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true,
                "description": "Primera comprobación (on_change) que encontró el texto de la página sin cambios desde esta captura."
              },
              "unchanged_last_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              },
              "unchanged_checks": {
                "type": "integer",
                "description": "Comprobaciones sin cambios registradas (como máximo una por hora)."
              }
            }
          }
        }
      },
      "WebhookEventType": {
        "type": "string",
        "enum": [
          "capture.completed",
          "capture.failed",
          "certify.completed",
          "certify.failed",
          "redaction.completed",
          "redaction.failed",
          "monitor.change_detected",
          "capture.anchor_confirmed",
          "schedule.paused"
        ],
        "description": "Un evento del catálogo (etiqueta Webhooks)."
      },
      "WebhookSubscriptionFilters": {
        "type": "object",
        "description": "Como máximo uno de host y schedule_id. La suscripción recibe entonces solo los eventos de las filas con ese host (nombre de host en minúsculas de la URL capturada) o de esa programación. `{}` para ninguno.",
        "properties": {
          "host": {
            "type": "string",
            "example": "www.example.com"
          },
          "schedule_id": {
            "type": "string",
            "example": "sch_a1B2c3D4e5F6g7H8"
          }
        }
      },
      "WebhookSubscription": {
        "type": "object",
        "properties": {
          "subscription_id": {
            "type": "string",
            "example": "whs_a1B2c3D4e5F6g7H8"
          },
          "url": {
            "type": "string",
            "description": "Endpoint https al que se envían los eventos mediante POST."
          },
          "events": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/WebhookEventType"
            }
          },
          "filters": {
            "$ref": "#/components/schemas/WebhookSubscriptionFilters"
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "disabled"
            ]
          },
          "env": {
            "type": "string",
            "enum": [
              "live",
              "test"
            ],
            "description": "El entorno de la credencial que la creó. Una suscripción de producción solo recibe los eventos de filas de producción; una suscripción de prueba, solo los de filas de prueba."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "created_by": {
            "type": "string",
            "enum": [
              "key",
              "session"
            ],
            "description": "key: creada con una clave de API; revocar esa clave desactiva la suscripción (disabled_reason key_revoked)."
          },
          "consecutive_failures": {
            "type": "integer",
            "description": "Entregas fallidas desde el último éxito."
          },
          "last_success_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "last_failure_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "last_error": {
            "type": "string",
            "nullable": true,
            "description": "Clase de error de la última entrega fallida (timeout, connection_failed, blocked_address, redirect_not_followed, http_<status>)."
          },
          "disabled_reason": {
            "type": "string",
            "nullable": true,
            "enum": [
              "gone",
              "failing",
              "key_revoked",
              null
            ],
            "description": "gone: el endpoint respondió con 410. failing: todas las entregas fallaron durante 5 días sin ningún éxito. key_revoked: se revocó la clave de API que la creó. PATCH status active la reactiva."
          },
          "disabled_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        },
        "required": [
          "subscription_id",
          "url",
          "events",
          "filters",
          "description",
          "status",
          "env",
          "created_at",
          "created_by",
          "consecutive_failures",
          "last_success_at",
          "last_failure_at",
          "last_error",
          "disabled_reason",
          "disabled_at"
        ]
      },
      "WebhookSubscriptionCreated": {
        "type": "object",
        "properties": {
          "subscription_id": {
            "type": "string",
            "example": "whs_a1B2c3D4e5F6g7H8"
          },
          "url": {
            "type": "string",
            "description": "Endpoint https al que se envían los eventos mediante POST."
          },
          "events": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/WebhookEventType"
            }
          },
          "filters": {
            "$ref": "#/components/schemas/WebhookSubscriptionFilters"
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "disabled"
            ]
          },
          "env": {
            "type": "string",
            "enum": [
              "live",
              "test"
            ],
            "description": "El entorno de la credencial que la creó. Una suscripción de producción solo recibe los eventos de filas de producción; una suscripción de prueba, solo los de filas de prueba."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "created_by": {
            "type": "string",
            "enum": [
              "key",
              "session"
            ],
            "description": "key: creada con una clave de API; revocar esa clave desactiva la suscripción (disabled_reason key_revoked)."
          },
          "secret": {
            "type": "string",
            "description": "whsec_ seguido de base64 estándar. Se muestra UNA SOLA VEZ: cada entrega a esta suscripción se firma con él (Standard Webhooks). Guárdalo ahora; no se puede volver a leer. Una repetición con Idempotency-Key en 24 horas devuelve el mismo cuerpo, secreto incluido."
          }
        },
        "required": [
          "subscription_id",
          "url",
          "events",
          "filters",
          "description",
          "status",
          "env",
          "created_at",
          "created_by",
          "secret"
        ]
      },
      "Event": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "evt_a1B2c3D4e5F6g7H8"
          },
          "type": {
            "$ref": "#/components/schemas/WebhookEventType"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "env": {
            "type": "string",
            "enum": [
              "live",
              "test"
            ]
          },
          "data": {
            "type": "object",
            "description": "El cuerpo del evento tal como lo reciben las suscripciones, sin subscription_id y sin enlaces prefirmados (package_url, package_url_expires_at, upgraded_proof_url, compare.package_url)."
          }
        },
        "required": [
          "id",
          "type",
          "created_at",
          "env",
          "data"
        ]
      },
      "PrivateNote": {
        "type": "string",
        "nullable": true,
        "maxLength": 200,
        "description": "Tu nota privada sobre la captura (por ejemplo \"mobile capture\"), de 200 caracteres como máximo, texto plano (la regla PlainText). Solo se almacena en el registro de la captura: NO forma parte del paquete de pruebas sellado (no está en el ZIP, el PDF, el manifiesto ni en ningún hash) y no tiene valor probatorio, por lo que se puede cambiar o borrar más tarde con PATCH /v1/captures/{capture_id} sin afectar a las pruebas. Solo la lee quien hizo la captura. Omítela, o envía null o una cadena vacía, para no tener nota.",
        "example": "mobile capture"
      },
      "CaptureEvent": {
        "type": "object",
        "description": "Un evento de la cadena de eventos de captura de la cuenta. hash = SHA-256 (hex) del JSON canónico de los campos con hash (v, chain, seq, type, capture_id, manifest_sha256, actor, access, detail, at, prev_hash; claves ordenadas, sin espacios en blanco, valores ausentes como null). prev_hash es el hash del evento anterior, 64 ceros para seq 1.",
        "properties": {
          "v": {
            "type": "integer",
            "enum": [
              1
            ]
          },
          "chain": {
            "type": "string",
            "description": "La cuenta: el uid para las capturas personales, org_<org_id> para las capturas de una empresa."
          },
          "seq": {
            "type": "integer",
            "minimum": 1
          },
          "type": {
            "type": "string",
            "enum": [
              "trash",
              "restore",
              "erase",
              "purge",
              "retention_deletion",
              "account_deletion",
              "company_deletion",
              "legal_hold_on",
              "legal_hold_off",
              "s3_legal_hold",
              "import"
            ]
          },
          "capture_id": {
            "type": "string"
          },
          "manifest_sha256": {
            "type": "string",
            "nullable": true,
            "description": "SHA-256 del manifest.json de la captura, leído antes de cualquier eliminación."
          },
          "actor": {
            "type": "string",
            "description": "El uid de la cuenta que actúa, o system para los barridos periódicos del servidor."
          },
          "access": {
            "type": "string",
            "description": "author, company_owner, matter_owner o system."
          },
          "detail": {
            "type": "string",
            "nullable": true,
            "description": "legal_hold_on / legal_hold_off: el origen de la retención (manual, bulk, matter:<id>, derived:<capture_id>) o, para una liberación por el sistema (actor system), su código de motivo subscription_ended; s3_legal_hold: el resultado de S3 (on, off, failed, unsupported, none); import: el tipo del paquete importado. Sin texto libre."
          },
          "at": {
            "type": "string",
            "format": "date-time",
            "description": "Cuándo se escribió el evento en la cadena. Nunca anterior al at del evento previo."
          },
          "prev_hash": {
            "type": "string"
          },
          "hash": {
            "type": "string"
          },
          "occurred_at": {
            "type": "string",
            "format": "date-time",
            "description": "Cuándo se confirmó la propia acción (la transacción del cambio de estado). No forma parte del hash. Normalmente igual a at con una diferencia de milisegundos; posterior solo cuando el servidor reintentó la adición a la cadena (la acción se registra en la misma transacción que el cambio, así que no se pierde ninguna)."
          }
        }
      },
      "CaptureEventAnchor": {
        "type": "object",
        "description": "Un anclaje diario de OpenTimestamps de la cadena: head_hash es el hash del evento to_seq, que compromete criptográficamente todos los eventos anteriores.",
        "properties": {
          "anchor_id": {
            "type": "string"
          },
          "day": {
            "type": "string",
            "format": "date"
          },
          "from_seq": {
            "type": "integer"
          },
          "to_seq": {
            "type": "integer"
          },
          "head_hash": {
            "type": "string"
          },
          "ots_proof_base64": {
            "type": "string",
            "nullable": true,
            "description": "La prueba de OpenTimestamps (archivo .ots, base64) sobre head_hash."
          },
          "ots_status": {
            "type": "string",
            "enum": [
              "pending",
              "bitcoin",
              "unconfirmed"
            ],
            "description": "pending: ots_proof_base64 contiene el compromiso de los calendarios (ots verify consulta a los calendarios); bitcoin: se sustituyó por la prueba actualizada al bloque de Bitcoin (bitcoin_block_height); unconfirmed: no se devolvió ninguna prueba de Bitcoin en 14 días, la prueba almacenada sigue verificándose a través de los calendarios."
          },
          "bitcoin_block_height": {
            "type": "integer",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ImportSummary": {
        "type": "object",
        "description": "Los tres niveles de un paquete verificado.",
        "properties": {
          "kind_label_key": {
            "type": "string",
            "description": "Clave de la etiqueta del tipo de paquete, import_kind_<kind>."
          },
          "integrity": {
            "type": "string",
            "enum": [
              "verified",
              "failed"
            ]
          },
          "time_anchor": {
            "type": "string",
            "enum": [
              "eidas_qualified",
              "eidas_qualified_revocation_unknown",
              "ots_bitcoin_attested_unconfirmed",
              "ots_pending",
              "none"
            ]
          },
          "provenance": {
            "type": "string",
            "nullable": true,
            "enum": [
              "issued_by_proofsnap",
              "unverified",
              "registered_by_extension_account",
              "not_registered",
              "unknown",
              null
            ]
          },
          "registered_at": {
            "type": "string",
            "format": "date-time",
            "description": "Cuándo anotó el paquete el registro de la extensión (provenance registered_by_extension_account)."
          },
          "issued_at": {
            "type": "string",
            "format": "date-time",
            "description": "Cuándo anotó la emisión el registro de emisión de la API (provenance issued_by_proofsnap)."
          }
        }
      },
      "Import": {
        "type": "object",
        "properties": {
          "import_id": {
            "type": "string",
            "example": "imp_a1B2c3D4e5F6g7H8i9J0"
          },
          "status": {
            "type": "string",
            "enum": [
              "uploading",
              "verifying",
              "accepted",
              "rejected",
              "duplicate",
              "expired"
            ]
          },
          "filename": {
            "type": "string"
          },
          "size_bytes": {
            "type": "integer"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "upload_expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "Solo uploading: completa antes de esta hora (24 horas después de la creación)."
          },
          "finished_at": {
            "type": "string",
            "format": "date-time",
            "description": "Cuándo alcanzó la importación su estado definitivo."
          },
          "capture_id": {
            "type": "string",
            "description": "Solo accepted: la nueva captura (el mismo id que la importación)."
          },
          "duplicate_of": {
            "type": "string",
            "description": "Solo duplicate: tu captura que ya contiene este manifiesto."
          },
          "kind": {
            "type": "string",
            "enum": [
              "web_capture_v21",
              "web_capture_legacy",
              "session",
              "file_certification_single",
              "file_certification_combined",
              "api_capture",
              "api_certify",
              "api_compare",
              "api_redaction"
            ],
            "description": "El tipo de paquete, una vez detectado."
          },
          "summary": {
            "$ref": "#/components/schemas/ImportSummary"
          },
          "reasons": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "rejected y expired: códigos de motivo, por ejemplo file_mismatch, file_missing, unlisted_file, bad_signature, no_hashed_files, unknown_schema, zip_too_large, test_package, ots_digest_mismatch, eidas_token_failed, forensic_chain_broken, size_mismatch, upload_not_completed, verification_incomplete. Solo códigos, nunca nombres de archivo."
          },
          "zip_sha256": {
            "type": "string",
            "description": "SHA-256 del ZIP subido, una vez calculado."
          },
          "manifest_sha256": {
            "type": "string",
            "description": "SHA-256 del manifest.json del paquete (aceptado y duplicado)."
          },
          "matter_id": {
            "type": "string",
            "description": "El asunto indicado al crear la importación."
          },
          "matter_assignment": {
            "type": "string",
            "enum": [
              "assigned",
              "skipped"
            ],
            "description": "accepted con matter_id: si la captura se añadió al asunto (se omite cuando para entonces el asunto estaba sellado, archivado o ya no estaba abierto para ti; la importación sigue siendo accepted)."
          },
          "upload": {
            "type": "object",
            "description": "Solo con ?upload=true mientras status es uploading.",
            "properties": {
              "upload_id": {
                "type": "string"
              },
              "part_size": {
                "type": "integer"
              },
              "parts": {
                "type": "array",
                "description": "URL PUT prefirmadas de las partes aún no recibidas; envía con PUT exactamente size_bytes.",
                "items": {
                  "type": "object",
                  "properties": {
                    "part_number": {
                      "type": "integer"
                    },
                    "url": {
                      "type": "string"
                    },
                    "size_bytes": {
                      "type": "integer"
                    }
                  }
                }
              },
              "expires_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              },
              "received": {
                "type": "array",
                "nullable": true,
                "items": {
                  "type": "object",
                  "properties": {
                    "part_number": {
                      "type": "integer"
                    },
                    "etag": {
                      "type": "string"
                    },
                    "size_bytes": {
                      "type": "integer"
                    }
                  }
                }
              },
              "completed": {
                "type": "boolean",
                "description": "La subida ya se había completado: vuelve a enviar complete."
              }
            }
          }
        }
      },
      "ImportVerification": {
        "type": "object",
        "description": "El informe de verificación registrado en la importación (sin contenido de archivos ni detalles en texto libre).",
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "kind": {
            "type": "string",
            "enum": [
              "web_capture_v21",
              "web_capture_legacy",
              "session",
              "file_certification_single",
              "file_certification_combined",
              "api_capture",
              "api_certify",
              "api_compare",
              "api_redaction"
            ]
          },
          "format": {
            "type": "string"
          },
          "kind_label_key": {
            "type": "string"
          },
          "evidence_id": {
            "type": "string",
            "description": "El id que lleva el paquete (ps_..., FC-..., cap_..., cert_..., cmp_..., red_...)."
          },
          "manifest_sha256": {
            "type": "string"
          },
          "zip_sha256": {
            "type": "string"
          },
          "anchor_sha256": {
            "type": "string",
            "description": "El hash que cubren los sellos de tiempo: el del manifiesto o, en la certificación de un único archivo, el del archivo certificado."
          },
          "anchor_source": {
            "type": "string",
            "enum": [
              "manifest",
              "certified_file"
            ]
          },
          "checks": {
            "type": "object",
            "description": "structure, signature, files (listed, verified, presence_only, unhashed, os_junk_ignored), custody, forensic_chain, ots, eidas, test_mode.",
            "additionalProperties": true
          },
          "summary": {
            "$ref": "#/components/schemas/ImportSummary"
          },
          "reasons": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "CaptureImport": {
        "type": "object",
        "description": "Solo en una captura con source \"import\".",
        "properties": {
          "import_id": {
            "type": "string"
          },
          "package_kind": {
            "type": "string",
            "enum": [
              "web_capture_v21",
              "web_capture_legacy",
              "session",
              "file_certification_single",
              "file_certification_combined",
              "api_capture",
              "api_certify",
              "api_compare",
              "api_redaction"
            ]
          },
          "kind_label_key": {
            "type": "string"
          },
          "package_format": {
            "type": "string"
          },
          "original_evidence_id": {
            "type": "string"
          },
          "original_filename": {
            "type": "string",
            "nullable": true
          },
          "imported_at": {
            "type": "string",
            "format": "date-time"
          },
          "captured_at_basis": {
            "type": "string",
            "enum": [
              "package_claim"
            ],
            "description": "El captured_at de un paquete importado es la hora que indica el paquete, nunca una hora observada por ProofSnap."
          },
          "manifest_sha256": {
            "type": "string"
          },
          "zip_sha256": {
            "type": "string"
          },
          "summary": {
            "$ref": "#/components/schemas/ImportSummary"
          },
          "levels": {
            "type": "object",
            "properties": {
              "integrity": {
                "type": "string"
              },
              "time_anchor": {
                "type": "string"
              },
              "provenance": {
                "type": "string",
                "nullable": true
              }
            }
          },
          "verification": {
            "$ref": "#/components/schemas/ImportVerification"
          },
          "provenance": {
            "type": "object",
            "properties": {
              "level": {
                "type": "string"
              },
              "source": {
                "type": "string",
                "nullable": true,
                "enum": [
                  "api_ledger",
                  "extension_registry",
                  null
                ]
              },
              "issued_at": {
                "type": "string",
                "nullable": true
              },
              "registered_at": {
                "type": "string",
                "nullable": true
              },
              "reason": {
                "type": "string"
              }
            }
          }
        }
      },
      "Rule902DisabledWarnings": {
        "type": "array",
        "description": "Presente solo cuando la solicitud pidió la plantilla de certificación (include_rule_902: true o, en una captura, un evidence_pack.custodian no vacío) mientras la plantilla está desactivada. La solicitud se aceptó y el indicador se ignoró: no se genera ningún rule_902_certification.pdf.",
        "items": {
          "type": "object",
          "properties": {
            "code": {
              "type": "string",
              "enum": [
                "RULE902_DISABLED"
              ]
            },
            "message": {
              "type": "string",
              "example": "The FRE 902 certification template (rule_902_certification.pdf) is currently disabled. include_rule_902 was accepted and ignored: the package does not contain the template."
            }
          },
          "required": [
            "code",
            "message"
          ]
        }
      }
    }
  }
}
