{
  "openapi": "3.0.3",
  "info": {
    "title": "ProofSnap API",
    "version": "2.0.0",
    "description": "The ProofSnap API loads a web page in a server-side Chromium browser and returns an evidence package (ZIP).\n\nEach capture package contains the screenshot, the page HTML, the extracted page text, metadata.json, forensic_log.json, chain_of_custody.json, evidence.pdf and a manifest.json that lists the SHA-256 of every file. manifest.json is signed with an RSA-4096 key generated for that capture (the public key ships as publickey.pem, the private key is discarded) and the SHA-256 of manifest.json is submitted to OpenTimestamps calendars for a Bitcoin anchor (manifest.json.ots). If the calendar request fails, the package ships without the proof and manifest.json does not list it, so the offline scripts and the verifier report it as not present rather than missing. With `include_eidas` on a live key, the same hash also receives an eIDAS qualified timestamp (RFC 3161, manifest.json.tsr). JPEG screenshots are signed with C2PA Content Credentials on a best-effort basis: when signing fails, the capture completes without them. The verification/ folder holds scripts that check the package offline. Every manifest.json states `ots_listed_only_when_present: true` (manifest.json.ots is listed only when it is in the package) and `eidas_marker_version: 1`. It records `eidas_requested: true` only when the package ships with its qualified timestamp. If the token cannot be obtained, the manifest is re-sealed without it. A package that records `eidas_requested: true` but lacks manifest.json.tsr has therefore lost its token, and the offline scripts report FAIL. chain_of_custody.json records `bitcoin_ots_included` and lists, in `page_modifications.items[]`, every change made to the page before capture. manifest.json carries sealed_at (when it was built, after every file hash) and the capture server's clock offsets measured by SNTP; chain_of_custody.json lists the steps of the capture in the order they happened, each with the time it finished, and closes with custody_record_written (hashing, the signature and the timestamps come after it and are attested by manifest.json and the proofs).\n\nmetadata.json records:\n\n- `browser_identity`: the user agent and Client Hints actually sent.\n- `dns`: an independent DNS check of the address the browser connected to, with the answers of dns.google and cloudflare-dns.com and the outcome: confirmed, not_confirmed, resolvers_unavailable, connected_ip_unknown, skipped_ip_literal (the URL names an IP address, so there is no host name to look up) or skipped_internal_host (an internal host name, which is never sent to the public resolvers).\n- `url_at_load` and `url_at_screenshot`: the address right after the page loaded and when its image was taken (a page can change its own address, most often the part after #).\n- `redirect_chain`: the main document's redirects, oldest first, each with its HTTP status. The requested and final addresses are given in full; every hop in between has its query values replaced by [redacted] (`query_values_redacted`).\n- `tls.source`: where each TLS field comes from, either `browser_connection` or `browser_connection_and_separate_handshake`, the latter with the `at`, `ip` and `port` of the separate handshake that fetched certificate_chain.pem after the page loaded.\n\nWhen a requested step was not performed (an eIDAS timestamp, C2PA signing, the archive request, WHOIS, the second clock reading, a WARC truncated by the time limit, or a wait or content expansion skipped because of the time limit), evidence.pdf lists it with a short reason and the provenance certificate names it in one line.\n\nCaptures are asynchronous. POST /captures returns 202 with a capture_id; poll GET /captures/{capture_id} or pass `options.webhook_url`. A completed capture returns a presigned package_url that is valid for up to 24 hours (see package_url_expires_at); read the capture again for a fresh link.\n\nPaths below are relative to the server URL, which already contains the /v1 stage. `poll_path` values returned by the API include /v1.",
    "contact": {
      "email": "support@getproofsnap.com"
    }
  },
  "servers": [
    {
      "url": "https://api.getproofsnap.com/v1",
      "description": "ProofSnap API (EU)"
    }
  ],
  "security": [
    {
      "ApiKeyAuth": []
    }
  ],
  "tags": [
    {
      "name": "Captures",
      "description": "Create, list, read, delete and verify captures.\n\n**Rate limits.** `POST /captures`, `POST /captures/batch`, `POST /compare`, `POST /captures/{capture_id}/redact` and `POST /certify-hash` are rate limited per account in fixed 60-second windows: all API keys on the account and its dashboard session share one window (adding keys does not raise the limit). A batch or a multi-hash certification uses one unit per URL or hash. Default limits per window by plan:\n\n| Plan | Requests per 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| any other plan | 30 |\n\nThe following requests also draw from the same window, one unit per request unless stated otherwise: `POST /captures/{capture_id}/verify`, `PATCH /captures/{capture_id}` with action legal_hold, `GET /captures/hosts` (only when the list is recomputed, not when a cached result is returned), `POST /legal-holds` (one unit per 10 captures), `POST /captures/export-jobs`, and `POST /imports`, `POST /imports/{import_id}/complete` and `GET /imports/{import_id}?upload=true`.\n\nWhen the limit is exceeded, the API returns 429 RATE_LIMITED with `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers and `error.details.retry_after_seconds`. A request that needs more units than one window allows (a multi-hash certification larger than the per-window limit) can never succeed and returns 400 RATE_LIMIT_COST_TOO_HIGH with `error.details` `{ max_per_window, window_seconds, requested }`. Split it into smaller requests. A refused request consumes no units, and the headers report the true remaining budget.\n\n**Monthly limits.** Captures, comparisons, redactions and certifications on live keys count toward a monthly maximum per account (UTC calendar month). The accepted response carries `X-Quota-*` headers. Reaching the maximum returns 402 QUOTA_EXCEEDED with `error.details` `{ reason, used, limit, max_limit, cap, org_member }`, where `cap` is `plan_max`, `customer`, `member_owner` or `org`. While a live subscription is past due or unpaid, billable requests return 402 PAYMENT_REQUIRED; for such an account, captures (single and batch) are refused with this error regardless of the key environment, including test keys.\n\n**Running captures and test mode.** Each account can run only a limited number of captures at the same time (developer 10, startup 25, growth 100, legal_solo and legal_seats 25, custom 200, website_archive 25, sandbox 5). Above that limit, the API returns 429 CONCURRENT_CAPTURE_LIMIT; a batch starts the URLs that fit and lists the others in `not_admitted` (each with `code` CONCURRENT_CAPTURE_LIMIT, plus `in_flight_limit`), so send those again later. Test-mode work (a test key, or a session without linked billing) is limited to 200 units per account per UTC day (429 TEST_MODE_DAILY_LIMIT; `X-Test-Daily-*` headers), and a test-mode capture is never sent to the Internet Archive (`external_archive.skip_reason` \"test_mode\")."
    },
    {
      "name": "Compare and redact",
      "description": "Derive a new sealed package from existing captures: a comparison of two captures, or a redacted copy of one."
    },
    {
      "name": "Hash certification",
      "description": "Certify SHA-256 hashes of files you keep. Only the hashes are sent; the files never reach ProofSnap."
    },
    {
      "name": "Scheduled captures",
      "description": "Recurring captures (interval or cron) and change monitoring (on_change). Every run that takes a capture is a normal capture: it is metered and counts toward the monthly limits."
    },
    {
      "name": "Matters",
      "description": "Legal case workspaces with a hash-chained custody log. Available on the Legal plans (legal_solo, legal_seats); other plans receive 403 MATTERS_NOT_ENABLED, with two exceptions. The owner of a company whose subscription has ended may still list, open and export that company's matters until the company's data deletion date (data_delete_after); this access is read-only plus export, and GET /matters then adds read_only: true. An account on the Archive only plan may list, open and export its matters. Only the matter owner may rename, archive, manage members, seal, share or export it. The matter owner is its creator. On a company matter, the company owner becomes the matter owner only after the creator has left the company. A company owner who was added as a member has member rights only. Members receive 403 FORBIDDEN for those operations. Every matter write (create, PATCH, assigning captures, share and its revocation, seal, export) needs a confirmed email address (403 EMAIL_NOT_VERIFIED, for a dashboard session and for an API key of an account whose address is not confirmed) and, for a live caller (live API key, or a session with linked billing), is refused with 402 PAYMENT_REQUIRED while the subscription (or the company's) is past due or unpaid. An archived matter refuses renames, new members, new captures, sharing and export (409 MATTER_ARCHIVED) until it is reopened; a caller who cannot reopen it (the owner of an ended company, an Archive only account) may still export it."
    },
    {
      "name": "Webhooks",
      "description": "ProofSnap POSTs a JSON event to the `webhook_url` you pass with a capture, certification, redaction or schedule. The URL must use https, must not contain credentials (`user:password@`, 400 INVALID_URL) and must not point to a private, loopback, link-local, carrier-grade NAT, multicast, reserved or cloud-metadata address, including IPv6 forms that embed such an IPv4 address. This is checked when the request is accepted (422 SSRF_BLOCKED) and again at every delivery: the hostname is resolved, every address is checked, and the delivery connects only to an address that passed the check, so a host that later resolves to a blocked address is not contacted. When the hostname cannot be resolved, the delivery is not sent. On every route, an empty or whitespace-only webhook_url is treated as no webhook. /compare does not send webhooks.\n\n**Account subscriptions.** Instead of (or in addition to) a per-request webhook_url, subscribe an endpoint to account events with `POST /webhook-subscriptions`: every matching event of the account goes to it, whichever key, schedule or dashboard session caused it. Each subscription has its own secret (returned once when it is created), lists the event types it wants and may filter by `host` or `schedule_id`; a live subscription receives events for live captures only, and a test subscription receives events for test captures only. An API key lists and changes only the subscriptions of its own environment; the dashboard session sees all of them. Its deliveries carry `subscription_id` at the top level and no presigned link (package_url, package_url_expires_at, upgraded_proof_url and compare.package_url are removed; monitor.change_detected carries compare.compare_id): fetch the package with GET /captures/{capture_id}. Capture events also carry `capture_id` at the top level. The webhook-id of a subscription delivery is derived from the subscription, the capture (or schedule) and the event. The delivery rules and retries below apply. In addition, a 410 response disables the subscription immediately (disabled_reason gone), deliveries failing for 5 days without a success (and at least one full retry cycle of failed attempts) disable it (failing), and revoking the API key that created it disables it (key_revoked); PATCH status \"active\" reactivates it. Every event is also kept for 30 days in `GET /events`.\n\n**Headers**\n\nEvery delivery is signed per the [Standard Webhooks](https://www.standardwebhooks.com/) specification, so any official Standard Webhooks library can verify it:\n\n| Header | Value |\n| --- | --- |\n| `Content-Type` | `application/json` |\n| `webhook-id` | `msg_<delivery id>`: the same for the same event of the same capture, so a duplicate delivery carries the same id. Use it to deduplicate: process each webhook-id once. `GET /webhook-events/{capture_id}` returns the same id without the `msg_` prefix as `delivery_id` |\n| `webhook-timestamp` | Unix time in seconds when the delivery was sent. It is covered by the signature |\n| `webhook-signature` | `v1,<base64>`: HMAC-SHA256 over `<webhook-id>.<webhook-timestamp>.<raw body>`. During a webhook secret rotation it carries two space-separated entries, `v1,<new> v1,<previous>`; accept the delivery when any entry matches |\n| `User-Agent` | `ProofSnap-Webhook/2.0` |\n\n**Verify a delivery.** Verify over the exact bytes you received, before parsing. Reject a delivery whose webhook-timestamp differs from your clock by more than 5 minutes (the official libraries do this), and deduplicate by webhook-id. The signing key is the part of the secret after `whsec_`, base64-decoded. `webhook_secret` is `whsec_` followed by standard base64, the form the official libraries expect, so pass it to them as is.\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\nWithout a library (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**Webhook secret.** Every API key has its own secret (`whsec_...`). It is shown once, when you create the key in the Dashboard under Developers (the `webhook_secret` field of the response that creates the key). It cannot be read back later: the key listing never returns it. If you lose it, or it may have leaked, generate a new one in the Dashboard under Developers (New webhook secret on the key). The new secret is also shown only once. **Rotation overlap:** for 24 hours after a rotation (until `previous_secret_expires_at` in the rotation response) every delivery is signed with both the new and the previous secret (two `v1,` entries in webhook-signature), so you can switch your endpoint to the new secret without dropping deliveries. A second rotation while that overlap is still running is refused with 409 WEBHOOK_SECRET_ROTATION_PENDING and `error.details.previous_secret_expires_at` (when you can rotate again), so no more than two secrets are ever active at once, and a secret that receivers may still rely on is never retired early. For a secret that leaked, revoke the key and create a new one. Revoking the key deletes both. Deliveries use the key's secrets at the moment they are sent, including deliveries for work started before a rotation. A request from a signed-in dashboard session has no API key and therefore no webhook secret, so a `webhook_url` on it is refused with 400 `WEBHOOK_REQUIRES_API_KEY` (captures, batches, redactions, hash certifications, creating or editing a scheduled capture). Scheduled runs use the secret of the key that created the schedule. When the key has been revoked, or its secret cannot be read at the moment of delivery, the event is not sent and the attempt is logged with the reason (read it with `GET /webhook-events/{capture_id}`).\n\n**Delivery.** Each attempt has a 10-second timeout. Redirects are never followed: a 3xx response counts as a failure, like a response outside 2xx or a timeout. The response body is not read. **Retries:** a delivery that times out, cannot connect or gets a response outside 2xx (except 410 Gone) is sent again about 5 minutes, 30 minutes, 2 hours, 8 hours and 24 hours later (attempts 2 to 6, about 35 hours in total), each time with the same `webhook-id` and a new `webhook-timestamp`. After that, the delivery is abandoned. A retried delivery carries the same payload WITHOUT the 24-hour download link (`package_url`, `package_url_expires_at`): fetch the package with GET /captures/{capture_id}. Deliveries for a capture erased meanwhile are dropped. A transient failure reading the signing secret on our side is retried the same way. A 410 Gone, a redirect, a blocked address, or a missing or revoked key is not retried. The body of a retry can differ from the first attempt (no download link): deduplicate by webhook-id, not by a hash of the body. Respond with 2xx quickly. The attempt log records a generic error class (connection_failed, timeout, blocked_address, redirect_not_followed, http_<status>), never the raw network error. certify.completed and redaction.completed are sent asynchronously, shortly after the response is returned. Every attempt is logged; read the log with `GET /webhook-events/{capture_id}`.\n\n**Events**\n\n`capture.completed`: a capture (single, batch item, or interval/cron schedule run) finished. Account webhook subscriptions also receive it for the baseline and change captures of an on_change schedule (the schedule's own webhook_url gets monitor.change_detected for those). `capture` has the same fields as a completed `GET /captures/{capture_id}`, plus `timestamps.eidas` when a qualified timestamp was added, `schedule_id` (null outside a schedule), `test_mode` and `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`: the capture could not be completed. `error.message` is a short public reason (for an unexpected failure \"The capture could not be completed.\"); internal error details are never sent. This includes a run that crashed or timed out: it is marked failed by a sweep about 30 minutes after it started, and capture.failed is sent then (message \"Capture did not finish (timed out).\"). The same sweep sends certify.failed or redaction.failed for a hash certification or redaction that did not finish. A run that finishes after it was reported failed is not billed and sends no 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`: a hash certification finished. `certificate` has the same fields as the synchronous `POST /certify-hash` response. Delivered asynchronously, shortly after the response is returned.\n\n`certify.failed`: a hash certification could not be completed.\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`: a redaction finished. `redaction` has the same fields as the `POST /captures/{capture_id}/redact` response.\n\n`redaction.failed`: a redaction could not be completed. `error.code` is the code the request would have returned (for example REDACTION_LEAK_DETECTED), with `details` when there are any; a redaction that the sweep marked as failed carries 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`: an `on_change` schedule saw the page text change and took a new capture.\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` and `metadata_verdict` (since 2026-10-14) mirror the per-category results of the comparison in compare.diff_summary (null without a comparison). A schedule with `alert_on: \"text\"` sends monitor.change_detected only when text_verdict is changed (or when there is no comparison result).\n\n`capture.anchor_confirmed` (subscriptions and GET /events only): the Bitcoin anchor of the package's OpenTimestamps proof was confirmed, usually a few hours after the capture.\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` (subscriptions and GET /events only): ProofSnap paused a schedule. `reason` is `no_billing` (no API access), `plan_downgrade` (the plan allows fewer schedules, no on_change, or no new work), `failures` (five failed runs in a row), `key_revoked` or `invalid_cron`. A pause you make yourself is not an event.\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` is `ok` (compare holds the comparison), `no_baseline` (no earlier capture to compare with) or `failed` (the comparison failed; compare is null)."
    },
    {
      "name": "Public",
      "description": "Operations that need no API key."
    },
    {
      "name": "Package import",
      "description": "Import existing ProofSnap evidence packages (ZIP files made by the ProofSnap extension: web captures, multi-tab sessions, single and combined file certifications; or by this API: captures, certifications, comparisons, redactions) into your account, so they are handled like your API captures: kept for 10 years and available for legal hold, matters and exports.\n\nLegal plans only (legal_solo, legal_seats), with a live API key or a dashboard session with linked billing (a test key, a session without billing and the sandbox get 403 IMPORT_NOT_ALLOWED). An email address that is not confirmed gets 403 EMAIL_NOT_VERIFIED, a past-due subscription 402 PAYMENT_REQUIRED. An import is not a capture: it is not counted against the monthly limits and has no per-import fee; it is billed as stored data only.\n\n**Flow.** 1. `POST /imports` with the file name and its exact size in bytes (at most 2 GiB): the response contains one presigned URL per 32 MiB part (the last part is smaller), valid for 1 hour. 2. PUT each part's bytes to its URL and keep the `ETag` response header of each. 3. `POST /imports/{import_id}/complete` with every part number and its ETag. 4. Poll `GET /imports/{import_id}` until the status is final.\n\n**Statuses.** `uploading` (waiting for the part uploads and the complete call; an upload not completed within 24 hours becomes `expired`), `verifying` (the package is being checked), `accepted` (it verified: `capture_id` is the new capture, the same id as the import), `rejected` (it did not verify: `reasons` lists the codes; nothing is kept but the import record), `duplicate` (your account already holds a capture with the same manifest: `duplicate_of`), `expired`.\n\n**Verification.** Strict: the RSA signature over manifest.json, every listed file present with the listed SHA-256 (and SHA-512 and size where listed), no files that the manifest does not list (except files the operating system adds, such as .DS_Store, and the proofs derived from the manifest), at least one hashed file, a consistent evidence id, the forensic log hash chain recomputed, the OpenTimestamps proof against the manifest (offline) and the eIDAS qualified timestamp against the pinned Disig root with the validation data in the package. ZIP structure is checked before anything is read (no encryption, no ZIP64, no links, safe names, no duplicate names, sizes and CRC-32 of every entry). A test package of the API (watermarked) is rejected. API packages sealed before evidence.pdf was hashed are accepted with that file listed in `verification.checks.files.unhashed`.\n\n**Levels** (`summary`): `integrity` (verified or failed), `time_anchor` (eidas_qualified, eidas_qualified_revocation_unknown, ots_bitcoin_attested_unconfirmed, ots_pending, none) and `provenance`: for API packages `issued_by_proofsnap` (the manifest is in the API's ledger of issued packages) or `unverified`; for extension packages `registered_by_extension_account` (a signed-in extension user registered that manifest when it was made) or `not_registered`; `unknown` when the lookup could not be made. Provenance never fails an import.\n\n**The capture.** An accepted import is a capture with `source` \"import\": it is downloadable (the uploaded ZIP byte for byte) and verifiable (POST /captures/{capture_id}/verify runs the same checks again), can be placed under legal hold, moved to the trash and erased, appears in matters (when imported with `matter_id`, the matter's custody log records `package.imported`) and in exports, and is kept 10 years from the import. Its `captured_at` is the time the package itself states (`import.captured_at_basis` \"package_claim\"), never a time ProofSnap observed; `created_at` is when the upload was created. Comparisons and redactions of an imported package are refused (422 IMPORT_NOT_SUPPORTED).\n\n**Limits.** At most 3 imports in progress (uploading or verifying) per account (409 IMPORT_LIMIT), 50 imports created per account and UTC day (429 RATE_LIMITED, `details.action` \"import_create\"; every create request counts, including one that is later rejected), and the per-minute rate limit of the plan on create and complete. Import volume: at most 5 GiB a UTC day and 20 GiB a UTC month of announced package sizes per account, counted at create (and at complete for an older upload); over it 429 IMPORT_DAILY_BYTES_LIMIT or IMPORT_MONTHLY_BYTES_LIMIT with `Retry-After`; a canceled upload does not count."
    }
  ],
  "paths": {
    "/captures": {
      "post": {
        "tags": [
          "Captures"
        ],
        "operationId": "createCapture",
        "summary": "Capture a URL",
        "description": "Accepts the capture and returns 202 at once; the capture runs in the background. Poll `GET /captures/{capture_id}` or pass `options.webhook_url`. The URL's hostname is resolved and checked before the capture is accepted.\n\nRate limited per account in 60-second windows (all API keys on the account and its dashboard session share one window). Once the limiter has run, responses from this route include the `RateLimit-Policy` and `RateLimit` headers (based on IETF draft-ietf-httpapi-ratelimit-headers) plus the legacy `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`. A 429 adds `Retry-After`.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CaptureRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accepted. An idempotent replay of an earlier request with the same Idempotency-Key also returns 202, with `Idempotent-Replay: true` and the original body.",
            "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 or INVALID_EVIDENCE_PACK_OPTIONS. WEBHOOK_REQUIRES_API_KEY: webhook_url sent from a dashboard session (webhooks need an API key).",
            "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 (monthly maximum or sandbox allowance; carries the X-Quota-* or X-Sandbox-* headers) or 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 with a test key (a ps_live_ key of an account without a linked billing account runs as a test key). EMAIL_NOT_VERIFIED: a dashboard session whose email address is not confirmed. PLAN_ARCHIVE_ONLY: the plan keeps the archive only and creates no new work. PLAN_API_READ_ONLY: an API key of a Website Archive account (its key is read only; use the dashboard). PLAN_API_READ_ONLY with details.reason \"session_outside_dashboard\": a dashboard session of a Website Archive account whose request has no Origin header or one that is not a ProofSnap dashboard address (new work on that plan is created in the dashboard only; a soft gate, not a security boundary).",
            "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 or 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: too many requests in the current 60-second window. `error.details.retry_after_seconds` repeats Retry-After. RateLimit reports r=0 and the seconds until the window resets. Also RATE_LIMITED, for a Website Archive dashboard session: its daily cap of 100 units per UTC day, shared by single and batch captures (one unit per URL), compare, redact and certify-hash (one unit per request); a request that does not fit is refused whole (details.daily_limit, details.remaining, details.action \"wa_session_captures\"; Retry-After and details.retry_after_seconds give the seconds to the next UTC day, and the per-minute RateLimit and X-RateLimit-* headers are left out). A short refusal with only details.retry_after_seconds and Retry-After: 1 means a concurrent update of the counter: retry. A refusal that is not the caller's fault gives the units back; work accepted with 202 that fails later keeps its unit. A soft gate, not a security boundary. A single capture whose Idempotency-Key is served from the store (a replay, 409 or 422) is served before the daily cap and costs no unit.\n\nCONCURRENT_CAPTURE_LIMIT: too many captures of the account are running at the same time (developer 10, startup 25, growth 100, legal_solo and legal_seats 25, custom 200, website_archive 25, sandbox 5); `details.limit`, `details.in_flight`, `details.requested` and Retry-After say when to try again.\n\nTEST_MODE_DAILY_LIMIT: test-mode work (a test key, or a session without linked billing) is limited to 200 units per account and UTC day; `details.used`, `details.limit`, `details.reset_at`, the `X-Test-Daily-*` headers and 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 (the capture could not be started; details.capture_id) or 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": [
          "Captures"
        ],
        "operationId": "listCaptures",
        "summary": "List your captures",
        "description": "Rows you created, newest first, in every status (including deleted ones, the trash; filter with status). Each row carries source, redacted_from, label and compared, so its type can be shown without reading it again. A capture made for a company is listed only while you may still act on it (you hold a seat of that company or own it); a page can therefore hold fewer rows than limit, and next_cursor continues after the last row read. Certificates, comparisons and redactions are included.\n\nLookup (own list only): url, host or schedule_id, combined with from / to and status, find the record of a page on a given day. A bad filter, two of url / host / schedule_id, or from not earlier than to is 400 MALFORMED_REQUEST.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Page size, 1 to 200 (default 50). A value outside the range is 400 MALFORMED_REQUEST.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "description": "next_cursor from the previous page (format created_at|capture_id, or completed_at|capture_id with order=completed_at). Pass it unchanged.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Only rows in this status: processing, completed, failed or deleted (trash). Any other value is 400 MALFORMED_REQUEST.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "url",
            "in": "query",
            "required": false,
            "description": "Only captures of this exact URL, as it was captured (http or https). At most one of url, host and schedule_id.",
            "schema": {
              "type": "string",
              "maxLength": 2048
            }
          },
          {
            "name": "host",
            "in": "query",
            "required": false,
            "description": "Only captures whose URL is on this hostname (case-insensitive, e.g. www.example.com; subdomains are separate hosts). At most one of url, host and schedule_id. Rows created before 2026-10-05 carry the host once the backfill ran.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "schedule_id",
            "in": "query",
            "required": false,
            "description": "Only captures made by this schedule. At most one of url, host and schedule_id.",
            "schema": {
              "type": "string",
              "pattern": "^sch_[A-Za-z0-9]{8,32}$"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "Created at or after this moment: a date (YYYY-MM-DD, UTC midnight) or an ISO 8601 timestamp.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "Created before this moment (exclusive): a date (YYYY-MM-DD, UTC midnight) or an ISO 8601 timestamp. Must be later than from. With schedule_id or url, the newest row before a moment is the version that was current then.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "order",
            "in": "query",
            "required": false,
            "description": "created_at (default: newest created first) or completed_at (newest completion first; only with status=completed, else 400 MALFORMED_REQUEST). With completed_at, from and to bound completed_at, and next_cursor has the form completed_at|capture_id. Works with url, host and schedule_id.",
            "schema": {
              "type": "string",
              "enum": [
                "created_at",
                "completed_at"
              ],
              "default": "created_at"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of captures.",
            "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": "When the capture completed (null while processing or after a failure)."
                          },
                          "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": "completed rows only. Same as external_archive of GET /captures/{capture_id}."
                          },
                          "label": {
                            "type": "string",
                            "nullable": true,
                            "description": "The row's label (certificates: the label given; redactions: which capture they redact)."
                          },
                          "compared": {
                            "type": "array",
                            "nullable": true,
                            "items": {
                              "type": "string"
                            },
                            "description": "Comparisons only: the two source capture IDs."
                          },
                          "source": {
                            "type": "string",
                            "nullable": true,
                            "description": "How the row was made: api (a web capture), schedule, certify-hash (a certificate), compare (a comparison) or redact (a redaction); null on rows older than the field."
                          },
                          "redacted_from": {
                            "type": "string",
                            "nullable": true,
                            "description": "Redactions only: the capture the redaction was made from."
                          },
                          "captured_at": {
                            "type": "string",
                            "nullable": true,
                            "format": "date-time",
                            "description": "When the page was captured (completed rows). For a redaction, the capture time of its source capture (redacted_at is when the redaction was made)."
                          },
                          "redacted_at": {
                            "type": "string",
                            "format": "date-time",
                            "description": "Redactions only: when the redaction was made. The captured_at of a redaction is the capture time of its source capture."
                          },
                          "compared_at": {
                            "type": "string",
                            "nullable": true,
                            "format": "date-time",
                            "description": "Comparisons only: when the comparison was made. Its captured_at carries the same value (kept for older readers); use compared_at."
                          },
                          "manifest_hash": {
                            "type": "string",
                            "nullable": true,
                            "description": "sha256:<hex> of the package's manifest.json (completed rows)."
                          },
                          "private_note": {
                            "type": "string",
                            "nullable": true,
                            "description": "Your private note on the capture (see PrivateNote), never part of the evidence package."
                          },
                          "legal_hold": {
                            "type": "boolean",
                            "description": "The capture is under legal hold (not deleted while true; released automatically when the subscription ended and its 30 days passed). See PATCH /captures/{capture_id}."
                          }
                        }
                      }
                    },
                    "next_cursor": {
                      "type": "string",
                      "description": "null when this page has fewer rows than limit.",
                      "nullable": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST: invalid cursor, status (one of processing, completed, failed, deleted), limit (1 to 200), or lookup filter (url, host, schedule_id, from, to; at most one of url / host / schedule_id; from earlier than to).",
            "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": [
          "Captures"
        ],
        "operationId": "createBatch",
        "summary": "Capture several URLs",
        "description": "Starts one capture per URL with the same options. Each capture is independent: poll each capture_id or receive one webhook per capture. Uses one rate-limit unit and one monthly-limit unit per URL. A batch with more URLs than one rate-limit window allows can never succeed, so it is refused with 400 RATE_LIMIT_COST_TOO_HIGH instead of 429. Split it into smaller requests. A replay of a completed batch (same Idempotency-Key and body) is served before the rate limit is applied and costs no rate-limit unit.\n\nRate limited per account in 60-second windows (all API keys on the account and its dashboard session share one window). Once the limiter has run, responses from this route include the `RateLimit-Policy` and `RateLimit` headers (based on IETF draft-ietf-httpapi-ratelimit-headers) plus the legacy `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`. A 429 adds `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": "Accepted. An idempotent replay of a completed batch with the same Idempotency-Key and body also returns 202, with `Idempotent-Replay: true`; it is served before the rate limit is applied, so it costs no rate-limit unit.",
            "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 and received, or index, url and a nested reason object for the invalid URL), MALFORMED_REQUEST or an options error. WEBHOOK_REQUIRES_API_KEY: webhook_url sent from a dashboard session (webhooks need an API key). RATE_LIMIT_COST_TOO_HIGH: more URLs than one rate-limit window allows; 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 or PAYMENT_REQUIRED. A batch is also refused with QUOTA_EXCEEDED when the monthly usage cannot be read at that moment; retry.",
            "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 with a test key. EMAIL_NOT_VERIFIED: a dashboard session whose email address is not confirmed. PLAN_ARCHIVE_ONLY: the plan keeps the archive only and creates no new work. PLAN_API_READ_ONLY: an API key of a Website Archive account (its key is read only; use the dashboard). PLAN_API_READ_ONLY with details.reason \"session_outside_dashboard\": a dashboard session of a Website Archive account whose request has no Origin header or one that is not a ProofSnap dashboard address (new work on that plan is created in the dashboard only; a soft gate, not a security boundary).",
            "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: too many requests in the current 60-second window. `error.details.retry_after_seconds` repeats Retry-After. RateLimit reports r=0 and the seconds until the window resets. Also RATE_LIMITED, for a Website Archive dashboard session: its daily cap of 100 units per UTC day, shared by single and batch captures (one unit per URL), compare, redact and certify-hash (one unit per request); a request that does not fit is refused whole (details.daily_limit, details.remaining, details.action \"wa_session_captures\"; Retry-After and details.retry_after_seconds give the seconds to the next UTC day, and the per-minute RateLimit and X-RateLimit-* headers are left out). A short refusal with only details.retry_after_seconds and Retry-After: 1 means a concurrent update of the counter: retry. A refusal that is not the caller's fault gives the units back; work accepted with 202 that fails later keeps its unit. A soft gate, not a security boundary.\n\nCONCURRENT_CAPTURE_LIMIT: no URL of the batch could start because the account already has as many captures running as it may (developer 10, startup 25, growth 100, legal_solo and legal_seats 25, custom 200, website_archive 25, sandbox 5); `details.limit`, `details.in_flight`, `details.requested` and Retry-After. When only some fit, the batch is accepted with 202 and lists the rest in `not_admitted`.\n\nTEST_MODE_DAILY_LIMIT: test-mode work (a test key, or a session without linked billing) is limited to 200 units per account and UTC day, one per URL; `details.used`, `details.limit`, `details.reset_at`, the `X-Test-Daily-*` headers and 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": [
          "Captures"
        ],
        "operationId": "listCaptureHosts",
        "summary": "List the hostnames you have captured",
        "description": "Every hostname of your own capture list, with the number of captures and the latest capture time per host, for a domain filter: pass a host back as GET /captures?host=. count is the number of rows GET /captures?host=<host> returns without status (among the captures read): test and live captures alike, every status including trashed ones (status deleted), as the list shows them; erased captures and rows without a page URL (certificates) are left out. A capture counts once it carries its stored hostname (captures created before 2026-10-05 carry it once the backfill ran). A company capture counts only while you may still act on it (you hold a seat of that company or own it). host is lower case. Sorted by count (highest first), then host (A to Z). At most 1000 hosts. The newest 5000 captures are read; truncated is true when there are more than 1000 hosts or captures older than those 5000, so a host captured only before them can be missing. The list is computed at most once per account every 30 seconds and can be up to 30 seconds old (a little older while another request of the account is computing it). Computing it costs one unit of your per-minute rate limit; a response served from that cache costs nothing. Available on every plan, to API keys and the dashboard session.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "The hostnames.",
            "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": "Lower-case hostname, the value GET /captures?host= takes."
                          },
                          "count": {
                            "type": "integer",
                            "minimum": 1,
                            "example": 12,
                            "description": "Captures of this host among the captures read: the rows GET /captures?host= returns without status, trashed ones included."
                          },
                          "last_captured_at": {
                            "type": "string",
                            "format": "date-time",
                            "nullable": true,
                            "example": "2026-10-05T07:48:38Z",
                            "description": "Latest capture time of this host (when the page was captured, else when the capture was requested), UTC, to the second."
                          }
                        }
                      }
                    },
                    "truncated": {
                      "type": "boolean",
                      "description": "true when the list may be incomplete: more than 1000 hosts, or more captures than the newest 5000 that were read."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST: scope is not supported (own captures only).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "description": "RATE_LIMITED: the per-minute rate limit is used up (computing the list costs one unit), or the list of your account is being computed by another request and no earlier result is stored; retry after Retry-After seconds.",
            "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": [
          "Captures"
        ],
        "operationId": "getCapture",
        "summary": "Get a capture",
        "description": "Status and, once completed, a fresh package_url valid for up to 24 hours (see package_url_expires_at). Works for capture, certificate, comparison and redaction IDs. Readable by the account that created it, members of the matter it belongs to, and the owner of the company it was made for (if the company's subscription has ended, until its download window closes).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "event",
            "in": "query",
            "required": false,
            "description": "Send download when you are downloading the package: for a capture in a matter this records capture.downloaded in the matter's access log. Plain status polls should omit it.",
            "schema": {
              "type": "string",
              "enum": [
                "download"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The capture.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CaptureResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "FORBIDDEN: not your capture.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "CAPTURE_NOT_FOUND: unknown or malformed ID, deleted capture, or the package file could not be found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      },
      "delete": {
        "tags": [
          "Captures"
        ],
        "operationId": "deleteCapture",
        "summary": "Delete a capture",
        "description": "Default: moves the capture to the trash. It can be restored with PATCH until restorable_until (30 days); after that it is purged. With permanent=true the stored package is erased now and the record is reduced to a tombstone; this cannot be undone. Neither removes packages you already downloaded or the OpenTimestamps proof. A capture in an open or archived matter is first removed from the matter (recorded in its custody log) when the caller can open that matter; a caller who cannot (its author after being removed from the matter) is refused with 409 MATTER_LINKED, for the trash and for permanent=true alike, until the matter owner removes it from the matter; a capture in a sealed matter cannot be deleted. A capture that is still processing cannot be deleted (409 CAPTURE_PROCESSING); delete it once it has completed or failed. Deleting a capture that never completed gives its monthly admission back.\n\nLegal hold release grace: for 14 days after a legal hold was released (`deletion_blocked_until` in GET) the capture is not permanently deleted (409 CAPTURE_HOLD_GRACE with `details.until`), by a person or by the trash purge or retention (the account and company deletion after the subscription ended does not wait for it). A trash is allowed; its purge waits for the end of the grace. A permanent deletion first lifts any S3 Object Lock legal hold still left on the stored objects; when the storage refuses, the request returns 409 CAPTURE_LEGAL_HOLD (\"still locked in storage\", retry later) and nothing is deleted. Every trash, restore and permanent deletion is recorded in the account's capture event chain (GET /captures/events). A redaction or comparison whose source capture is under legal hold returns `source_under_legal_hold: true` when it is trashed or deleted.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "permanent",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "true"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted. Trash: { capture_id, status: \"deleted\", restorable: true, restorable_until }. Permanent: { capture_id, status: \"deleted\", permanent: true }. Already in the trash: { 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": "Only present (true) when this is a redaction or comparison made from a capture that is under legal hold."
                    },
                    "deletion_blocked_until": {
                      "type": "string",
                      "format": "date-time",
                      "description": "Trash during the release grace: the trash purge waits until this time."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "FORBIDDEN: not your capture. FORBIDDEN also when the capture belongs to a matter that is not sealed and the caller (the company owner acting on a seat's capture, not its author) cannot open that matter. PLAN_API_READ_ONLY: an API key of a Website Archive account (its key is read only; use the dashboard).",
            "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: the capture is an exhibit of a sealed matter. MATTER_LINKED: the capture is an exhibit of a matter the caller no longer belongs to; the matter owner removes it first. CAPTURE_PROCESSING: the capture is still processing (error.details.status). CAPTURE_LEGAL_HOLD: the capture is under legal hold and is not deleted (trash or erase), by anyone. CAPTURE_HOLD_GRACE: a permanent deletion within the grace after the capture's legal hold was released (LEGAL_HOLD_RELEASE_GRACE_DAYS, 14 days); error.details.until says when it becomes possible. Moving it to the trash is allowed during the grace.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "INTERNAL_ERROR: the stored package could not be erased; nothing was deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      },
      "patch": {
        "tags": [
          "Captures"
        ],
        "operationId": "restoreCapture",
        "summary": "Restore a deleted capture, set its private note, or place or release its legal hold",
        "description": "Restores a capture from the trash. If it was removed from a matter when it was deleted, it is added back when that matter is still open and the caller can still open it; matter_id and matter_relinked are in the response only then (matter_id null when the re-link did not happen). A caller who can no longer open that matter gets the capture back without it, nothing is written to the matter's custody log, and the response does not mention the matter. A capture that is not in the trash is returned unchanged.\n\nPrivate note: a body with `private_note` (a string of at most 200 characters, or null to clear it) sets the capture's private note instead and does nothing else; it cannot be combined with `action`. The note is stored on the capture record only, outside the sealed evidence package, so changing it never alters the evidence or its hashes. Only the capture's maker may set it (the company owner and matter members get 403), on a capture in any state except a permanently deleted one (404). The response is { capture_id, status, private_note }.\n\nLegal hold: a body `{ \"action\": \"legal_hold\", \"enabled\": true|false, \"reason\"?: string }` places (true) or releases (false) the capture's legal hold and does nothing else. A capture under legal hold is not deleted: DELETE (trash or permanent) returns 409 CAPTURE_LEGAL_HOLD for everyone, and neither the trash purge nor the retention deletion touches it; GET shows `storage.retention.earliest_deletion_at` null. A hold is kept only while the account has a plan that keeps data (any paid plan, Archive only included; for a company capture: the company is active or kept as a company archive). When the subscription ends, the captures stay available for 30 days; if no such plan is bought by then, every legal hold of the account (or the company) is released automatically on that date (actor system, reason subscription_ended, recorded like any release, no release grace) and the captures are deleted with the others. To keep held captures, switch to Archive only (or buy the company archive) before that date, or download them. In those 30 days no new hold can be placed (the account has no paid plan), existing holds can still be released. The hold is stored on the capture record only, outside the sealed evidence package. `reason` is optional plain text of at most 200 characters (no control, invisible or text-direction characters; whitespace is collapsed, blank means none); it is kept with the hold and is cleared when the hold is released.\n\nWho: placing a hold, anyone who may act on the capture (its author, for a company capture while still a seat of that company, and the company owner, also of a company whose subscription ended while it is within its retention or kept as a company archive; the archive through the dashboard session only). Releasing it: on a personal capture its author, on a company capture the company owner only (a seat gets 403 LEGAL_HOLD_OWNER_ONLY). Placing a hold needs a paid plan (every paid plan, Archive only included): the free sandbox, also an account whose subscription has ended, gets 403 LEGAL_HOLD_NOT_AVAILABLE with details.reason \"plan\" (a running company archive counts as paid for that company's captures, through the dashboard session of its owner), and a test-mode capture (watermarked, made with a test key or without linked billing) gets 403 LEGAL_HOLD_NOT_AVAILABLE with details.reason \"test_capture\". Releasing a hold works on every plan, also after the subscription ended. On Website Archive the API key is read only (403 PLAN_API_READ_ONLY), the dashboard session may. A past-due or unpaid subscription does not block it. A dashboard session or key whose email address is not confirmed gets 403 EMAIL_NOT_VERIFIED.\n\nRefused on a capture in the trash (409 CAPTURE_IN_TRASH: restore it first) or still processing (409 CAPTURE_PROCESSING); a permanently deleted capture, or one whose permanent deletion is under way, is 404. Each legal hold request costs one unit of the account's per-minute rate limit (429 RATE_LIMITED with Retry-After when it is used up). Sending the state the capture already has changes nothing and returns 200 with the stored fields and `changed: false`. Each change is audit-logged as capture.legal_hold (a company capture's in the company's tenant), recorded in the capture's access history (`legal_hold_on` / `legal_hold_off`, kept when the capture is later erased) and, when the capture belongs to a matter, written to the matter's custody log as capture.legal_hold_placed or capture.legal_hold_released with the capture id as its target (the reason goes to the matter's access log, never into the hashed chain; a sealed matter's chain does not change). The response is { capture_id, legal_hold, legal_hold_set_at, legal_hold_reason, changed }.\n\nLegal hold v2: placing or releasing a hold also sets or lifts an S3 Object Lock legal hold on every version of the stored package, its upgraded OpenTimestamps proof and the separately stored duplicate (`s3_legal_hold` in GET; when the storage refuses or has no Object Lock configuration the hold on the capture record still applies and a failed attempt is retried by the server). A release starts a grace of 14 days during which the capture is not permanently deleted (not after the subscription ended: the 30 days after the end replace it). A hold placed by its matter's legal hold (`legal_hold_source` \"matter:<id>\") is released on the matter while that hold is on: releasing it here is 409 LEGAL_HOLD_MATTER (error.details.matter_id for a reader of the matter). Each change is recorded in the account's capture event chain (GET /captures/events).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "action": {
                    "type": "string",
                    "description": "restore (the default for a body without private_note) or legal_hold (with enabled and the optional reason).",
                    "enum": [
                      "restore",
                      "legal_hold"
                    ]
                  },
                  "private_note": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/PrivateNote"
                      }
                    ],
                    "description": "Set (a string) or clear (null or an empty string) the private note. Cannot be combined with action."
                  },
                  "enabled": {
                    "type": "boolean",
                    "description": "With action legal_hold (required there): true places the legal hold, false releases it."
                  },
                  "reason": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 200,
                    "description": "With action legal_hold, optional: why the capture is held, plain text of at most 200 characters. Stored with the hold, outside the evidence package; ignored when releasing (it then goes to the audit log only)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Restored ({ capture_id, status, restored: true, matter_id?, matter_relinked? }), unchanged ({ capture_id, status }), the private note set ({ capture_id, status, private_note }), or the legal hold set ({ 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": "Legal hold action: the capture's hold after the request."
                    },
                    "legal_hold_set_at": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true,
                      "description": "Legal hold action: when the current hold was placed (null when there is none)."
                    },
                    "legal_hold_reason": {
                      "type": "string",
                      "nullable": true,
                      "description": "Legal hold action: the reason stored with the current hold, or null."
                    },
                    "changed": {
                      "type": "boolean",
                      "description": "Legal hold action: false when the capture already had the requested state (nothing changed or was logged)."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST: invalid JSON, a body that is not an object, an action other than restore or legal_hold, private_note combined with action, or a private_note that is not a string or null, is longer than 200 characters or contains control, invisible or text-direction characters. For legal_hold: enabled missing or not a boolean, a reason that is not a string, longer than 200 characters or not plain text, or an unknown field.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "FORBIDDEN: not your capture. FORBIDDEN also when the capture belongs to a matter that is not sealed and the caller (the company owner acting on a seat's capture, not its author) cannot open that matter. PLAN_API_READ_ONLY: an API key of a Website Archive account (its key is read only; use the dashboard). For private_note: FORBIDDEN for anyone but the capture's maker (who must still be allowed to act on it). For legal_hold: FORBIDDEN when the caller may not act on the capture; LEGAL_HOLD_OWNER_ONLY when a seat (not the company owner) releases the hold of a company capture; LEGAL_HOLD_NOT_AVAILABLE when placing a hold without a paid plan (details.reason plan) or on a test-mode capture (details.reason test_capture); EMAIL_NOT_VERIFIED for an unconfirmed email address. The matter rule above does not apply to a legal hold.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "CAPTURE_NOT_FOUND, including a capture that was deleted permanently.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Legal hold only. CAPTURE_IN_TRASH: the capture is in the trash; restore it first. CAPTURE_PROCESSING: the capture is still processing (error.details.status). LEGAL_HOLD_MATTER: the capture's hold was placed by its matter's legal hold, which is still on; release it on the matter. LEGAL_HOLD_SOURCE: the hold of a redaction or comparison that follows a capture it was made from (for a comparison, either compared capture), which is still under legal hold (placed on that capture or through its matter): the package stays held until that hold is released (`details.source_capture_id`). A comparison is held while either compared capture is held and released only when neither is (`legal_hold_source` \"derived:<capture id>\" names the held one).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Legal hold only. RATE_LIMITED: the per-minute rate limit of the account is used up. `error.details.retry_after_seconds` repeats 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": [
          "Captures"
        ],
        "operationId": "verifyCapture",
        "summary": "Verify a stored package",
        "description": "Reads the stored package and checks the RSA signature of manifest.json and the SHA-256 of every file it lists. Packages larger than 256 MiB return 413 VERIFY_PACKAGE_TOO_LARGE; verify those offline with the bundled scripts. Each request costs one unit of the account's per-minute rate limit (429 RATE_LIMITED). When the package carries manifest.json.tsr, the eIDAS timestamp token is also checked by ProofSnap (checks.eidas): the token's signature and signed attributes, that it carries the SHA-256 of manifest.json, the extendedKeyUsage of the TSA certificate, its certificate chain to the pinned root CA Disig QCA4 as of the token time (genTime), and the TSA certificate status from the OCSP response or CRL recorded at issuance in eidas_validation.json (verified by signature, not fetched live). This check never requests a new timestamp. It also reports whether manifest.json.ots is present and whether the manifest hash is in the ProofSnap issuance ledger. The verdict depends only on the manifest signature and the listed files; a token that fails its check is reported in checks.eidas and in warnings (EIDAS_TOKEN_FAILED), not folded into the verdict. A package issued with a qualified timestamp records eidas_requested: true in its signed manifest.json; when such a package no longer carries manifest.json.tsr, checks.eidas and checks.eidas_disig are reported as missing with the warning EIDAS_TOKEN_MISSING (the bundled verify scripts report FAIL for the same package). Allowed for the capture's owner, members of its matter and the owner of the company it was made for. To check the timestamps independently, run the scripts in the package's verification/ folder.\n\nImported packages (source import): the stored ZIP is rechecked with the import checks (signature, every file, custody, forensic chain, timestamps; provenance is looked up again in the API ledger for API packages and taken from the import record for extension packages). The response is { 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 }. A package above 512 MiB, or a check that does not finish in about 20 seconds, returns rechecked: false with a reason (too_large_for_synchronous_check, time_budget, storage_error), verdict: null and stored_verification (the report recorded at import time).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Verification result.",
            "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: unknown, deleted, or no stored package.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "VERIFY_PACKAGE_TOO_LARGE: the stored package is above 256 MiB.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "RATE_LIMITED: the per-minute rate limit of the account is used up.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "CONFIGURATION_ERROR: the stored package could not be read.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/captures/{capture_id}/redact": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CaptureId"
        }
      ],
      "post": {
        "tags": [
          "Compare and redact"
        ],
        "operationId": "redactCapture",
        "summary": "Create a redacted copy",
        "description": "Builds a new package from a completed capture with the selected content removed, re-signs it (new RSA-4096 key) and submits it for an OpenTimestamps anchor. The original is not changed. Synchronous: returns 201 when the package is ready. Metered as one capture.\n\n- Box redactions black out areas of the screenshot. A JPEG screenshot in the form the capture writes it (sequential, grayscale or YCbCr) is redacted without decoding it to pixels: the compressed blocks (MCUs, 8 to 32 pixels on a side) a box touches are replaced with black blocks and every other block is kept unchanged, so the rest of the image is not recompressed (`screenshot_method` `jpeg_dct_blocks`, up to 150 megapixels). The blacked-out area is each box rounded out to whole blocks (`painted_rects`), so it always contains the box. A PNG screenshot, or a JPEG of another kind, is decoded, painted and re-encoded as screenshot.jpeg, then the 8 x 8 blocks a box touches are blacked out in the new file (`screenshot_method` `decode_repaint_jpeg`, up to 60 megapixels). A screenshot above its limit or with unreadable image data is refused with 422 REDACTION_SCREENSHOT_UNREADABLE before anything is charged. If the header is readable but the image data is not, the error is detected after the monthly unit is reserved, and that unit is released. Text and metadata redactions still work without boxes. redaction_manifest.json (`screenshot_boxes`) records the method, `painted_rects`, the block grid and an explanatory note for forensic examiners.\n- Text redactions remove matches from page.html and domtextcontent.txt, from every free-text value of metadata.json (page title and meta values, WHOIS / RDAP records, the TLS subject, HTTP response header values), and from every free-text value of forensic_log.json and chain_of_custody.json (case_reference, case_id, case_note, your metadata values, injected code and its errors, the labels of closed pop-ups, legal_context.note). Identifiers, hashes, times and URLs in those records are left unchanged. Encoded occurrences are removed too: HTML character references (named, decimal, hex, such as M&uuml;ller or jan&#64;firma.cz), percent-encoding (mailto:jan%40firma.cz) and JavaScript escapes (\\u0040, \\x40), including encodings nested up to two levels deep; the marker replaces the encoded characters themselves. The post-redaction check for surviving matches reads the JSON files value by value and searches every text both as written and decoded, so it fails on a match in any of those forms. Text redactions also black out the screenshot where the source capture's text_layout.json places an occurrence (`text_rule_rects`): the estimated lines of the text block that hold the match are painted, and the whole block only when the estimate is uncertain (counted in `screenshot_boxes.from_text_rules.rects_whole_block` of redaction_manifest.json); occurrences it does not place are counted in `text_occurrences_not_located`. Rules match the exact letter case unless `case_insensitive` is set.\n- Metadata redactions remove fields from metadata.json. Two names reach further. `ip_dns` (also accepted: `ip`, `dns`, `resolved_ips`) removes the target site's IP addresses and DNS answers from metadata.json, forensic_log.json and chain_of_custody.json (the capture machine's own addresses stay), and also removes them, whole addresses only, from page.html, domtextcontent.txt and the screenshot as automatic text rules (recorded with source ip_dns only, never the address or a hash of it, not counted against the text_redactions limit). `cookie_names` (also `cookie`, `cookies`, `http_headers.cookie`) removes the Cookie and Set-Cookie headers and every list of cookie names in those records; the counts are kept. Cookie values are already redacted in every capture.\n\nA box only covers pixels. Text under a box stays in page.html, domtextcontent.txt and metadata.json unless you also add a text redaction, and an image under a box may still be referenced or embedded in page.html (img src or srcset, CSS url(), a data: URI); a text redaction that matches its address removes that reference. The redacted package carries only screenshot.jpeg (or screenshot.png when no box replaced it), page.html, domtextcontent.txt, metadata.json and forensic_log.json from the original, plus the files it writes anew (evidence.pdf, chain_of_custody.json, redaction_manifest.json, the verification files and the manifest files). Every other file of the original, such as images/, image_inventory.json, text_layout.json, page.mhtml, network.har and capture.warc, is left out and listed in redaction_manifest.json under dropped_for_leak_protection. Text redactions are applied to these file names too, because an image copy is named after its URL. chain_of_custody.json keeps the original's events about them, marked artifact_removed_in_redaction and without the hashes of files the redacted package does not contain. A rule_902_certification.pdf of the original is removed too, because it recites the original's files and hashes; the removal is listed in redaction_manifest.json (removed_as_stale) and in chain_of_custody.json. The capture URL is never redacted because it is part of the signed manifest: a text redaction whose text is also in the capture URL or the final URL is applied everywhere else, the text stays in that URL, and the rule is listed in `rules_in_capture_url` and in redaction_manifest.json (leakage_check.capture_url). Only web page captures can be redacted (a comparison, certification or redaction is refused with 422 REDACTION_SOURCE_UNSUPPORTED).\n\nA redaction of a test capture (made with a test key, in the sandbox or by an account without a linked billing account) is a test package regardless of which key requests it: watermarked, `test_mode: true` in manifest.json, a test row (key_env test) that is not metered and uses no monthly unit. include_eidas on such a redaction is refused with 403 EIDAS_REQUIRES_LIVE_KEY (details.reason).\n\nThe redaction report (evidence.pdf) is rendered in `language` (default: the language of the source capture), including its body, cover and test banner.\n\nBefore anything is redacted, every file the redaction reads or carries (the screenshot, page.html, domtextcontent.txt, metadata.json, forensic_log.json, chain_of_custody.json, text_layout.json) that the source manifest lists with a SHA-256 is re-hashed (redaction_manifest.json derived_from.source_files_rehashed); a mismatch or a missing file returns 422 REDACTION_SOURCE_INTEGRITY_FAILED and nothing is issued, so a corrupt or altered source is never re-sealed under a new signature. Files the redacted package leaves out are not read. A synchronous redaction whose source took too long to read stops before anything is reserved, with 500 CAPTURE_TIME_BUDGET_EXCEEDED.\n\nA redaction of a capture under legal hold is held too (legal_hold_source \"derived:<capture id>\"), and so is every redaction of a capture when a hold is placed on it; releasing the capture's hold releases those holds, and while the capture stays held they cannot be released on their own (409 LEGAL_HOLD_SOURCE). A redaction row's captured_at is the capture time of its source; redacted_at is when the redaction was made.\n\nThe redaction fails closed: when the post-redaction check still finds a redacted term anywhere in the package outside the capture URL and the final URL, no package is issued and the request returns 422 REDACTION_LEAK_DETECTED (`error.details` `{ files, rule_indexes, metadata_fields, in_capture_url }`; the term itself is never echoed). redaction_manifest.json records `leakage_check.fail_closed: true`.\n\nValues are truncated at character boundaries (a character is never split): text match strings to 2000 characters, reasons to 200, reason_summary to 500, custodian fields to 200. Reasons, reason_summary and custodian fields are printed on the redaction certificate and must be plain text: a control, invisible or text-direction character is refused with 400.\n\nTime budget and retries: the request returns within API Gateway's 29-second limit. The sealing runs under a time budget counted from the request; if the qualified timestamp (include_eidas) would not fit within the budget, it is not requested and the package ships with the OpenTimestamps anchor only (timestamps.eidas status not_obtained, reason time_budget, not billed), and an OpenTimestamps request that does not fit is left out (chain_of_custody.json records ots_skipped with reason time_budget). Send an Idempotency-Key so that a retry (for example after a 504 from a gateway or a network error) returns the same redaction instead of creating and billing a second one. A retry while the first request is still running returns 409 IDEMPOTENCY_IN_PROGRESS with the redaction ID in `error.details.capture_id` (poll GET /captures/{capture_id} with it). A later retry returns 201 with the original body and `Idempotent-Replay: true`. A redaction that fails releases its key, so a retry with the same key runs again.\n\nRate limited per account in 60-second windows (all API keys on the account and its dashboard session share one window). Once the limiter has run, responses from this route include the `RateLimit-Policy` and `RateLimit` headers (based on IETF draft-ietf-httpapi-ratelimit-headers) plus the legacy `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`. A 429 adds `Retry-After`.\n\nAn imported evidence package (source import, IDs prefixed `imp_`) cannot be redacted: 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": "Rectangles in pixels of the source capture's screenshot (x and y from its top left corner, w and h the width and height). When all four values of a box are between 0 and 1 they are read as fractions of the screenshot's width and height. A box that lies entirely outside the screenshot paints nothing and is listed in ignored_boxes; a box that extends past an edge is cut at the edge and listed in clipped_boxes. When every box falls outside and no text or metadata redaction changes anything, the request is refused with 400 REDACTION_NO_OPS before anything is charged."
                  },
                  "text_redactions": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "match": {
                          "oneOf": [
                            {
                              "type": "string",
                              "description": "Literal text.",
                              "minLength": 1
                            },
                            {
                              "type": "object",
                              "properties": {
                                "pattern": {
                                  "type": "string",
                                  "description": "Regular expression, 1 to 500 characters. Patterns with nested quantifiers such as (a+)+ are rejected with 400.",
                                  "minLength": 1,
                                  "maxLength": 500
                                },
                                "flags": {
                                  "type": "string",
                                  "description": "Any of g, i, m, s, u, each at most once. y (sticky) is refused with 400: it would match at one position only and leave other occurrences.",
                                  "pattern": "^[gimsu]*$"
                                }
                              },
                              "required": [
                                "pattern"
                              ]
                            }
                          ]
                        },
                        "case_insensitive": {
                          "type": "boolean",
                          "default": false,
                          "description": "Match a literal string without regard to case (for a regular expression use the i flag)."
                        },
                        "reason": {
                          "type": "string",
                          "maxLength": 200,
                          "description": "Plain text, printed on the redaction certificate."
                        }
                      },
                      "required": [
                        "match"
                      ]
                    },
                    "maxItems": 200
                  },
                  "metadata_fields": {
                    "type": "array",
                    "description": "Field paths in metadata.json (a `*` segment matches every array item), or the names `ip_dns`, `cookie_names` and `authorization` (see above).",
                    "items": {
                      "oneOf": [
                        {
                          "type": "string",
                          "description": "Field path in metadata.json."
                        },
                        {
                          "type": "object",
                          "properties": {
                            "field": {
                              "type": "string"
                            },
                            "reason": {
                              "type": "string"
                            }
                          },
                          "required": [
                            "field"
                          ]
                        }
                      ]
                    },
                    "maxItems": 100
                  },
                  "reason_summary": {
                    "type": "string",
                    "maxLength": 500,
                    "description": "Plain text, printed on the redaction certificate."
                  },
                  "custodian": {
                    "type": "object",
                    "properties": {
                      "name": {
                        "type": "string"
                      },
                      "title": {
                        "type": "string"
                      }
                    },
                    "description": "Printed on the redaction report. name and title follow the plain-text rule (see PlainText)."
                  },
                  "language": {
                    "type": "string",
                    "description": "Language of the human-readable PDFs. One of: 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. Case, hyphen and region are tolerated (\"DE\", \"fr-FR\" and \"PT-BR\" are accepted); bare \"pt\" and \"zh\" are rejected. An unsupported value returns 400 MALFORMED_REQUEST. Optional: without it (or blank) the report is in the language of the source capture."
                  },
                  "include_eidas": {
                    "type": "boolean",
                    "description": "As in CaptureOptions.include_eidas.",
                    "default": false
                  },
                  "webhook_url": {
                    "type": "string",
                    "description": "https URL that receives redaction.completed. It is sent right after the 201 response, not before it. The URL must be https and must not point to a private, loopback, link-local, carrier-grade NAT or cloud-metadata address (the same rules as a capture URL; 422 SSRF_BLOCKED otherwise). An empty or whitespace-only value means no webhook. Needs an API key: sent from a dashboard session it is refused with 400 WEBHOOK_REQUIRES_API_KEY, because webhooks are signed with the key's webhook secret.",
                    "format": "uri"
                  },
                  "rule_commitment_salt": {
                    "type": "string",
                    "description": "Optional. Your own 32-byte salt for the rule commitments (redaction_manifest.json rule_commitments), as 64 hex digits or base64 / base64url; a salt of one repeated byte is refused (400). Send it to hold the salt before the call: without it the server makes one and returns it only in the 201 response, so a response lost to a timeout (504 from the gateway while the redaction still finished) loses the salt, and an Idempotency-Key replay does not carry it. ProofSnap does not keep it."
                  }
                },
                "description": "At least one of boxes, text_redactions or metadata_fields."
              },
              "example": {
                "text_redactions": [
                  {
                    "match": "jane.doe@example.com",
                    "reason": "personal data"
                  }
                ],
                "reason_summary": "Personal data of a third party"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Redacted package ready. An idempotent replay of an earlier redaction with the same Idempotency-Key also returns 201, with `Idempotent-Replay: true`: the original body, with the current status and a freshly presigned package_url (left out when the package cannot be presigned; read it with 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": "Number of redaction actions applied."
                    },
                    "original_signature_verified": {
                      "type": "boolean",
                      "description": "Whether the original manifest signature verified before redacting."
                    },
                    "leakage_warnings": {
                      "type": "integer",
                      "description": "Always 0: a redaction whose post-redaction check finds a surviving term is refused with 422 REDACTION_LEAK_DETECTED instead of being issued. Kept for compatibility."
                    },
                    "package_url": {
                      "type": "string",
                      "format": "uri"
                    },
                    "package_url_expires_at": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "verify_url": {
                      "type": "string",
                      "description": "Link to the public ProofSnap verifier.",
                      "format": "uri"
                    },
                    "ignored_boxes": {
                      "type": "array",
                      "items": {
                        "type": "integer",
                        "minimum": 0
                      },
                      "description": "Zero-based indexes into the request's boxes of the boxes that lay entirely outside the screenshot and painted nothing. Also recorded in redaction_manifest.json (screenshot_boxes)."
                    },
                    "clipped_boxes": {
                      "type": "array",
                      "items": {
                        "type": "integer",
                        "minimum": 0
                      },
                      "description": "Zero-based indexes into the request's boxes of the boxes that extended past the screenshot's edge and were cut at it. Also recorded in redaction_manifest.json (screenshot_boxes)."
                    },
                    "screenshot_method": {
                      "type": "string",
                      "nullable": true,
                      "enum": [
                        "jpeg_dct_blocks",
                        "decode_repaint_jpeg",
                        null
                      ],
                      "description": "How the boxes were painted: jpeg_dct_blocks (the JPEG's compressed blocks under each box replaced with black blocks, every other block unchanged) or decode_repaint_jpeg (the screenshot decoded, painted and re-encoded). null when no box painted anything. Also recorded in 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": "The areas actually blacked out, in pixels of the screenshot, one per applied box in the order of the request's boxes without the ignored ones. With jpeg_dct_blocks each is the box rounded out to whole blocks, so it contains the box; with decode_repaint_jpeg it is the box clamped to the screenshot and rounded out to whole 8 x 8 blocks of the new file, which are blacked out after the re-encode so the compression cannot carry neighbouring colour into the box. Also recorded in redaction_manifest.json (screenshot_boxes.painted_rects)."
                    },
                    "text_rule_rects": {
                      "type": "integer",
                      "minimum": 0,
                      "description": "Distinct areas of the screenshot blacked out for the text redactions only, without the areas that hold a box (an area two rules match counts once): in the text blocks of the source's text_layout.json whose text a rule matches, the estimated lines that hold the match, or the whole block when the estimate is uncertain. Listed in redaction_manifest.json (screenshot_boxes.from_text_rules)."
                    },
                    "text_occurrences_not_located": {
                      "type": "integer",
                      "minimum": 0,
                      "description": "Occurrences of the text redactions in the page text that could not be located on the screenshot and were not blacked out there (text the source's text_layout.json does not list, such as text inside an image; a block too large to black out for one term; areas past the limit of one redaction; a screenshot that could not be painted). Occurrences in a block entirely outside the screenshot (nothing there to black out) are not counted here; redaction_manifest.json lists them as occurrences_not_on_screenshot. Never more than the occurrences in the page text. Check the screenshot and add boxes. redaction_manifest.json (leakage_check.screenshot_text_advisory) has the counts per rule."
                    },
                    "text_variants_remaining": {
                      "type": "object",
                      "description": "Advisory, never a failure: per literal text rule, the occurrences left in this package's domtextcontent.txt and in the text of its page.html that the rule did not match but that match when letter case and diacritics are ignored (for example \"boskovické\" for the case-sensitive rule \"Boskovic\"), and whether the term is in the capture URL or the final URL, which are never redacted. `total` adds, per rule, the larger of the two counts (both files hold the same page text). Counts only, never the text. Regex rules are not counted. Also in redaction_manifest.json (leakage_check.text_variants_advisory). Present when the request has text redactions.",
                      "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": "Present when the request has text redactions. Zero-based indexes of the text rules whose text is also in the capture URL or the final URL. Those URLs are never changed: the text was removed everywhere else and stays visible there. Empty when no rule matches them. Also in redaction_manifest.json (leakage_check.capture_url, `remains_in_capture_url: true` per rule) and in the redaction report."
                    },
                    "rule_commitment_salt": {
                      "type": "string",
                      "pattern": "^[0-9a-f]{64}$",
                      "description": "Present when the request has text redactions. The 32-byte salt (hex; the request's rule_commitment_salt when one was sent) of redaction_manifest.json rule_commitments: per text rule, the SHA-256 of the rule's salt followed by the UTF-8 bytes of the rule's canonical JSON {\"match\": <text, or {\"pattern\", \"flags\"}>, \"case_insensitive\": <bool>}, where the rule's salt is HMAC-SHA256 keyed with this salt over the UTF-8 text \"rule:<rule_index>\" (so one rule can be shown by disclosing its own salt, without the others). The rules are not stored, and the salt is returned only in this response: not in an Idempotency-Key replay, a webhook, the event log or GET /captures/{capture_id}, and ProofSnap does not keep it. Keep it with your records: with it you can show which rules the redaction applied."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST (details.reason; for example a reason that is not plain text or the regex flag y), REDACTION_NO_OPS (nothing supplied, or nothing matched) or INVALID_URL (webhook_url not https). WEBHOOK_REQUIRES_API_KEY: webhook_url sent from a dashboard session (webhooks need an API key).",
            "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 or 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: not your capture. EIDAS_REQUIRES_LIVE_KEY: include_eidas with a test key, or for a redaction of a test capture (then with details.reason). EMAIL_NOT_VERIFIED: a dashboard session whose email address is not confirmed. PLAN_ARCHIVE_ONLY: the plan keeps the archive only and creates no new work. PLAN_API_READ_ONLY: an API key of a Website Archive account (its key is read only; use the dashboard). PLAN_API_READ_ONLY with details.reason \"session_outside_dashboard\": a dashboard session of a Website Archive account whose request has no Origin header or one that is not a ProofSnap dashboard address (new work on that plan is created in the dashboard only; a soft gate, not a security boundary).",
            "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: the source capture is not completed. IDEMPOTENCY_IN_PROGRESS: a request with the same Idempotency-Key is still running (details.capture_id is the redaction ID to poll with GET /captures/{capture_id}). COMPANY_READ_ONLY: the source belongs to a company kept as a company archive.",
            "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: the screenshot's image data is unreadable or the screenshot is above the box redaction limit (150 megapixels for a JPEG as the capture writes it, 60 megapixels for a PNG or another JPEG); text and metadata redactions still work without boxes. REDACTION_LEAK_DETECTED: a redacted term survived outside the capture URL (details { files, rule_indexes, metadata_fields, in_capture_url }; in_capture_url true when the refused rule's text is also in the capture URL or the final URL, where it is never counted). REDACTION_SOURCE_UNSUPPORTED: the source is not a web page capture (details { source } or { manifest_type }). SSRF_BLOCKED: webhook_url targets a blocked address. REDACTION_SOURCE_INTEGRITY_FAILED: a file of the source package does not match the SHA-256 listed in its signed manifest, or a listed file is missing (details { mismatched, missing }, file names only); nothing was redacted or charged. IDEMPOTENCY_KEY_REUSED: the Idempotency-Key was already used with a different request body (or from another key environment).",
            "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: too many requests in the current 60-second window. `error.details.retry_after_seconds` repeats Retry-After. RateLimit reports r=0 and the seconds until the window resets. Also RATE_LIMITED, for a Website Archive dashboard session: its daily cap of 100 units per UTC day, shared by single and batch captures (one unit per URL), compare, redact and certify-hash (one unit per request); a request that does not fit is refused whole (details.daily_limit, details.remaining, details.action \"wa_session_captures\"; Retry-After and details.retry_after_seconds give the seconds to the next UTC day, and the per-minute RateLimit and X-RateLimit-* headers are left out). A short refusal with only details.retry_after_seconds and Retry-After: 1 means a concurrent update of the counter: retry. A refusal that is not the caller's fault gives the units back; work accepted with 202 that fails later keeps its unit. A soft gate, not a security boundary.",
            "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: unexpected server error; retry with exponential backoff. CAPTURE_TIME_BUDGET_EXCEEDED: the synchronous redaction ran out of its time budget and was stopped before any package was issued; nothing was charged. With `details.reason` `source_read`, reading the source package took most of the budget: the package is too large to redact right now, try again later.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/EidasOrAuthUnavailable"
          }
        },
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ]
      }
    },
    "/compare": {
      "post": {
        "tags": [
          "Compare and redact"
        ],
        "operationId": "compareCaptures",
        "summary": "Compare two captures",
        "description": "Compares two of your completed captures (page text, the images shown on the page, metadata) and produces a signed comparison package. Returns 202; poll GET /captures/{compare_id}, which then carries type, compared and diff_summary. The images are compared from the image inventory each source capture sealed (image_inventory.json: the address of every displayed image and the SHA-256 of its bytes), never pixel by pixel; the results are in image_changes.json and in diff_summary.image_changes. A source captured before captures recorded an image inventory is still compared, without the image comparison (diff_summary.image_changes.status not_run). Metered as one capture. Like for like (2026-10-14): two captures rendered with a different device profile or with different capture settings are refused with 422 COMPARE_PROFILE_MISMATCH (details.differing, details.before, details.after: only the differing fields, injected CSS and JavaScript as their SHA-256) unless allow_profile_mismatch is true, and two captures of different pages with 422 COMPARE_URL_MISMATCH (details.before and details.after: url and final_url as origin and path) unless allow_url_mismatch is true. The check runs on the two capture rows first (before anything is read or reserved), then on the sealed metadata.json and forensic_log.json of both packages within the source check below; a mismatch found only by the comparison run fails the comparison with that code and gives the monthly unit back. The text is compared line by line with a deterministic line diff (no timeout): diff_summary counts lines (text_unit line). Only web captures (made through the API or by a schedule) can be compared: a hash certification, a comparison or a redacted package is refused with 422 COMPARE_SOURCE_UNSUPPORTED (`details.capture_id`, `details.source`) before any package is read and before anything is reserved. Before anything is compared or reserved, the SHA-256 of every file listed with a SHA-256 in each source package's manifest.json is recomputed; a source with an altered or missing file, or a manifest that lists no file with a SHA-256, is refused with 422 COMPARE_SOURCE_INTEGRITY_FAILED (no comparison, no row, no monthly unit). Only those listed files are compared: a file inside a source package that its manifest does not list with a SHA-256 (for example an added text_layout.json, image_inventory.json, page.html or domtextcontent.txt) is treated as absent. The check runs on the request when both source packages are 50 MB or smaller and it finishes within about 10 seconds; otherwise the request is accepted and the comparison run repeats the check, and a failing source then fails the comparison (GET /captures/{compare_id} shows status failed with error.code COMPARE_SOURCE_INTEGRITY_FAILED) and gives the monthly unit back. A completed comparison records this check in compare.json `sources[].source_files_reverified` and in comparison_method.json `source_verification`. /compare does not send webhooks: a webhook_url in the body is ignored. Poll GET /captures/{compare_id}.\n\nA comparison that involves a test capture (made with a test key, in the sandbox or by an account without a linked billing account) is a test package regardless of which key requests it: watermarked, `test_mode: true` in manifest.json, `test_mode: true` for that source in compare.json `sources[]`, and a test row (key_env test) that is not metered and uses no monthly unit. include_eidas with a test source is refused with 403 EIDAS_REQUIRES_LIVE_KEY (details.reason).\n\nRate limited per account in 60-second windows (all API keys on the account and its dashboard session share one window). Once the limiter has run, responses from this route include the `RateLimit-Policy` and `RateLimit` headers (based on IETF draft-ietf-httpapi-ratelimit-headers) plus the legacy `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`. A 429 adds `Retry-After`.\n\nSend an Idempotency-Key so that a retry (for example after a 504) returns the first response instead of a second, billed run (24 hours, same rules as POST /captures). The key is claimed before the source check, so a retry while the first request is still running returns 409 IDEMPOTENCY_IN_PROGRESS; a request refused by the source check or the quota releases its key, so a retry with the same key is checked again.\n\nAn imported evidence package (source import, IDs prefixed `imp_`) cannot be compared: 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": "The earlier capture."
                  },
                  "capture_id_2": {
                    "type": "string",
                    "description": "The later capture. Must differ from capture_id_1."
                  },
                  "language": {
                    "type": "string",
                    "description": "Language of the comparison PDF. Exact code only; any other value silently falls back to English.",
                    "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": "As in CaptureOptions.include_eidas.",
                    "default": false
                  },
                  "include_rule_902": {
                    "type": "boolean",
                    "description": "Add an unsigned certification template (rule_902_certification.pdf, see CaptureOptions.include_rule_902) for the comparison. Not available for captures rendered differently: with allow_profile_mismatch true and a profile or settings mismatch, 422 RULE902_NOT_AVAILABLE_FOR_MISMATCH. Currently disabled: the certification template is temporarily switched off, so this field is accepted and ignored (no rule_902_certification.pdf is generated or listed in the package) and the response carries the warning RULE902_DISABLED.",
                    "default": false
                  },
                  "allow_profile_mismatch": {
                    "type": "boolean",
                    "default": false,
                    "description": "Compare two captures although they were made with a different device profile (options.viewport mobile or desktop, viewport_width, viewport_height, device_scale_factor; sealed: viewport size, device scale factor, mobile emulation, user agent without version numbers) or different capture settings that change how a page renders (dark_mode, block_ads, block_resources, inject_css, inject_js by SHA-256, expand_collapsed, dismiss_consent, dismiss_popups, full page). Without it such a pair is refused with 422 COMPARE_PROFILE_MISMATCH. With it, diff_summary.text_verdict and images_verdict are undetermined, changed is null unless a content metadata field differs, and include_rule_902 is refused."
                  },
                  "allow_url_mismatch": {
                    "type": "boolean",
                    "default": false,
                    "description": "Compare two captures of different pages. Two captures count as the same page when an address of one (the requested url, the url and final_url its metadata.json records) equals one of the other after normalization (host lower case, http and https the same, default port, fragment, a trailing slash and the volatile query parameters left out). Without it different pages are refused with 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": "Accepted.",
            "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: invalid JSON, a missing ID, the same ID twice, or allow_profile_mismatch / allow_url_mismatch that is not a boolean.",
            "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 or 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: a capture is not yours. EIDAS_REQUIRES_LIVE_KEY: include_eidas with a test key, or with a test source capture (then with details.reason). EMAIL_NOT_VERIFIED: a dashboard session whose email address is not confirmed. PLAN_ARCHIVE_ONLY: the plan keeps the archive only and creates no new work. PLAN_API_READ_ONLY: an API key of a Website Archive account (its key is read only; use the dashboard). PLAN_API_READ_ONLY with details.reason \"session_outside_dashboard\": a dashboard session of a Website Archive account whose request has no Origin header or one that is not a ProofSnap dashboard address (new work on that plan is created in the dashboard only; a soft gate, not a security boundary).",
            "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: both captures must be completed. COMPANY_READ_ONLY: a source belongs to a company kept as a company archive (nothing new is created from it).",
            "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: a file of a source capture does not match the SHA-256 its signed manifest lists, or is missing; nothing was compared or charged. `details.capture_id`, `details.mismatched`, `details.missing` name the first failing source; `details.reason` is present when the source manifest itself cannot be used (for example \"the source manifest lists no file with a SHA-256\" or \"the source manifest lists no files\"); `details.sources[]` lists every failing source with the same fields. Verify the source capture (POST /captures/{capture_id}/verify). COMPARE_SOURCE_UNSUPPORTED: a source is not a web capture (a hash certification, a comparison or a redacted package); nothing was read, compared or charged. `details.capture_id` names that source and `details.source` its kind when the row records one. Compare the original web captures instead. COMPARE_PROFILE_MISMATCH: the two captures were made with a different device profile or different capture settings; `details.differing` lists the field names (row level: 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; sealed level: viewport_width, viewport_height, device_scale_factor, is_mobile, user_agent and the same settings), `details.before` and `details.after` their values. Send allow_profile_mismatch: true to compare anyway. COMPARE_URL_MISMATCH: the two captures are of different pages; `details.before` and `details.after` give url and final_url as origin and path. Send allow_url_mismatch: true to compare anyway. RULE902_NOT_AVAILABLE_FOR_MISMATCH: include_rule_902 with allow_profile_mismatch for captures rendered differently; `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: too many requests in the current 60-second window. `error.details.retry_after_seconds` repeats Retry-After. RateLimit reports r=0 and the seconds until the window resets. Also RATE_LIMITED, for a Website Archive dashboard session: its daily cap of 100 units per UTC day, shared by single and batch captures (one unit per URL), compare, redact and certify-hash (one unit per request); a request that does not fit is refused whole (details.daily_limit, details.remaining, details.action \"wa_session_captures\"; Retry-After and details.retry_after_seconds give the seconds to the next UTC day, and the per-minute RateLimit and X-RateLimit-* headers are left out). A short refusal with only details.retry_after_seconds and Retry-After: 1 means a concurrent update of the counter: retry. A refusal that is not the caller's fault gives the units back; work accepted with 202 that fails later keeps its unit. A soft gate, not a security boundary.",
            "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": [
          "Hash certification"
        ],
        "operationId": "certifyHash",
        "summary": "Certify file hashes",
        "description": "Send one SHA-256 (hash) or up to 100 (hashes). The package contains manifest.json (with your hashes), manifest.sig, publickey.pem, evidence.pdf, the verification/ scripts and, when available, manifest.json.ots.\n\n- One hash: synchronous, returns 200 with the finished certificate.\n- Several hashes with combined: true: one certificate listing every hash, synchronous, returns 200.\n- Several hashes without combined: one independent certificate per hash, started in the background; returns 202 with the certify_ids. Poll GET /captures/{certify_id}; with webhook_url each sends certify.completed.\n\nEach certificate counts as one capture for metering, the monthly limit and the rate limit (N hashes without combined count N; N larger than the per-window limit is 400 RATE_LIMIT_COST_TOO_HIGH). A `label` is printed on the certificate: it is cut to 200 characters and must be plain text (no control, invisible or text-direction characters, 400 otherwise). In a batch, an error about one item names it in `error.details.index`.\n\nRate limited per account in 60-second windows (all API keys on the account and its dashboard session share one window). Once the limiter has run, responses from this route include the `RateLimit-Policy` and `RateLimit` headers (based on IETF draft-ietf-httpapi-ratelimit-headers) plus the legacy `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`. A 429 adds `Retry-After`.\n\nSend an Idempotency-Key so that a retry (for example after a 504) returns the first response instead of a second, billed run (24 hours, same rules as POST /captures).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "hash": {
                    "type": "string",
                    "description": "SHA-256 as 64 hex characters (case-insensitive). Use hash or hashes."
                  },
                  "label": {
                    "type": "string",
                    "description": "Name shown for the file. Longer values are shortened to 200 characters."
                  },
                  "hashes": {
                    "type": "array",
                    "items": {
                      "oneOf": [
                        {
                          "type": "string",
                          "description": "SHA-256 hex."
                        },
                        {
                          "type": "object",
                          "properties": {
                            "hash": {
                              "type": "string"
                            },
                            "label": {
                              "type": "string"
                            }
                          },
                          "required": [
                            "hash"
                          ]
                        }
                      ]
                    },
                    "minItems": 1,
                    "maxItems": 100
                  },
                  "combined": {
                    "type": "boolean",
                    "description": "Certify all hashes in one certificate.",
                    "default": false
                  },
                  "algorithm": {
                    "type": "string",
                    "enum": [
                      "sha256"
                    ],
                    "default": "sha256"
                  },
                  "language": {
                    "type": "string",
                    "description": "Language of the human-readable PDFs. One of: 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. Case, hyphen and region are tolerated (\"DE\", \"fr-FR\" and \"PT-BR\" are accepted); bare \"pt\" and \"zh\" are rejected. An unsupported value returns 400 MALFORMED_REQUEST."
                  },
                  "case_reference": {
                    "type": "string",
                    "description": "Printed on the certificate. Follows the plain-text rule (see PlainText); at most 200 characters.",
                    "maxLength": 200
                  },
                  "include_eidas": {
                    "type": "boolean",
                    "description": "As in CaptureOptions.include_eidas.",
                    "default": false
                  },
                  "include_rule_902": {
                    "type": "boolean",
                    "description": "Add an unsigned certification template (rule_902_certification.pdf, see CaptureOptions.include_rule_902). Currently disabled: the certification template is temporarily switched off, so this field is accepted and ignored (no rule_902_certification.pdf is generated or listed in the package) and the response carries the warning RULE902_DISABLED.",
                    "default": false
                  },
                  "webhook_url": {
                    "type": "string",
                    "description": "https URL that receives certify.completed. The URL must be https and must not point to a private, loopback, link-local, carrier-grade NAT or cloud-metadata address (the same rules as a capture URL; 422 SSRF_BLOCKED otherwise). An empty or whitespace-only value means no webhook. Needs an API key: sent from a dashboard session it is refused with 400 WEBHOOK_REQUIRES_API_KEY, because webhooks are signed with the key's webhook secret.",
                    "format": "uri"
                  }
                }
              },
              "example": {
                "hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
                "label": "contract.pdf"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Certificate ready (one hash, or combined).",
            "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": "Several independent certificates started. A certificate that the monthly limit or the sandbox allowance refuses once it runs fails: its row (GET /captures/{certify_id}) has status failed and 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": "IDs of the certificates that were created (including one whose start failed)."
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "processing"
                      ]
                    },
                    "certificates": {
                      "type": "array",
                      "description": "One entry per submitted hash, in order. status failed: that certificate could not be started (its row, when one was created, is marked failed); the others still run.",
                      "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": "How many entries of certificates failed to start. When every one failed the response is 500 CONFIGURATION_ERROR instead."
                    },
                    "warnings": {
                      "$ref": "#/components/schemas/Rule902DisabledWarnings"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST (details.reason names the field, for example \"label must be a string\"; details.index names the item of a batch), INVALID_HASH (details.index in a batch) or INVALID_URL (webhook_url not https, or with credentials). WEBHOOK_REQUIRES_API_KEY: webhook_url sent from a dashboard session (webhooks need an API key). RATE_LIMIT_COST_TOO_HIGH: more hashes (without combined) than one rate-limit window allows; 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, or QUOTA_EXCEEDED on the synchronous paths.",
            "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\" when a live key or a session runs as test because no billing account is linked). EMAIL_NOT_VERIFIED: a dashboard session whose email address is not confirmed, or an API key of such an account. PLAN_ARCHIVE_ONLY: the plan keeps the archive only and creates no new work. PLAN_API_READ_ONLY: an API key of a Website Archive account (its key is read only; use the dashboard). PLAN_API_READ_ONLY with details.reason \"session_outside_dashboard\": a dashboard session of a Website Archive account whose request has no Origin header or one that is not a ProofSnap dashboard address (new work on that plan is created in the dashboard only; a soft gate, not a security boundary).",
            "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 targets a private, loopback, link-local or otherwise blocked address.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "RATE_LIMITED: too many requests in the current 60-second window. `error.details.retry_after_seconds` repeats Retry-After. RateLimit reports r=0 and the seconds until the window resets. Also RATE_LIMITED, for a Website Archive dashboard session: its daily cap of 100 units per UTC day, shared by single and batch captures (one unit per URL), compare, redact and certify-hash (one unit per request); a request that does not fit is refused whole (details.daily_limit, details.remaining, details.action \"wa_session_captures\"; Retry-After and details.retry_after_seconds give the seconds to the next UTC day, and the per-minute RateLimit and X-RateLimit-* headers are left out). A short refusal with only details.retry_after_seconds and Retry-After: 1 means a concurrent update of the counter: retry. A refusal that is not the caller's fault gives the units back; work accepted with 202 that fails later keeps its unit. A soft gate, not a security boundary.",
            "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: the certification failed (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: the synchronous certification (one hash, or combined) did not finish within about 25 s. It may still complete: details.certify_id and details.poll_path name the row to poll with 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": "Webhook delivery log",
        "description": "Every delivery attempt for one capture, certificate or redaction, oldest first.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Delivery log.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "capture_id": {
                      "type": "string"
                    },
                    "webhook_url": {
                      "type": "string",
                      "description": "The webhook URL of the row (options.webhook_url of a capture, or the URL given with a certificate or redaction; null when there is none). For the owner of the company a capture was made for, reading a seat's log, only scheme, host, port and path (query and fragment removed, they may carry a token); that read is kept in the capture's access history (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 when delivered. Otherwise a generic class, never the raw network error: connection_failed, timeout, blocked_address (the host resolved to a private or otherwise blocked address at delivery time), redirect_not_followed (the endpoint responded with 3xx; redirects are never followed), http_<status> (a response outside 2xx), or a sentence saying why the event was not sent (no API key, key revoked or not found, no webhook secret, secret lookup failed)."
                          },
                          "delivery_id": {
                            "type": "string",
                            "description": "The webhook-id header of this delivery without the `msg_` prefix."
                          }
                        }
                      }
                    },
                    "attempt_count": {
                      "type": "integer"
                    },
                    "last_success": {
                      "type": "string",
                      "description": "attempted_at of the last successful attempt.",
                      "nullable": true
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "FORBIDDEN: not your capture.",
            "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": [
          "Scheduled captures"
        ],
        "operationId": "createSchedule",
        "summary": "Create a schedule",
        "description": "interval and cron: each run takes a capture and, with webhook_url, sends capture.completed or capture.failed.\n\non_change: each run first fetches the page (plain HTTP, or a browser render with js_render) and hashes its normalized text. That check is not a capture and is not billed. The first run takes a baseline capture. When the hash changes, a new capture is taken, compared with the previous one, and monitor.change_detected is sent to webhook_url. A page that returns an error or almost no text counts as no change.\n\nAfter 5 consecutive failed runs a schedule pauses itself. When the monthly limit is reached or the subscription is past due, runs are skipped and the schedule stays active.\n\nLimits per plan:\n\n| Plan | Active schedules | Minimum cadence | on_change |\n| --- | --- | --- | --- |\n| sandbox | 0 | not available | no |\n| developer | 3 | 300 s | yes |\n| startup | 25 | 300 s | yes |\n| growth | 200 | 300 s | yes |\n| custom | unlimited | 300 s | yes |\n| legal_solo | 3 | 300 s | yes |\n| legal_seats | 3 | 300 s | yes |\n| website_archive | 30 | 86400 s | yes |\n| archive_only | 0 | not available | no |\n\nIdempotency-Key: a retry with the same key and body within 24 hours gets the first 201 back (header Idempotent-Replay: true) instead of a second schedule; the same key with another body is 422 IDEMPOTENCY_KEY_REUSED.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ScheduleRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created.",
            "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) or a capture options error. WEBHOOK_REQUIRES_API_KEY: webhook_url sent from a dashboard session (webhooks need an API key).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "SCHEDULE_LIMIT_REACHED (details.max_active, details.active) or ON_CHANGE_REQUIRES_PLAN. EMAIL_NOT_VERIFIED: a dashboard session whose email address is not confirmed. PLAN_ARCHIVE_ONLY: the plan keeps the archive only and creates no new work. PLAN_API_READ_ONLY: an API key of a Website Archive account (its key is read only; use the dashboard). PLAN_API_READ_ONLY with details.reason \"session_outside_dashboard\": a dashboard session of a Website Archive account whose request has no Origin header or one that is not a ProofSnap dashboard address (new work on that plan is created in the dashboard only; a soft gate, not a security boundary).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "SSRF_BLOCKED: url or webhook_url targets a private, loopback, link-local or otherwise blocked address.",
            "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": [
          "Scheduled captures"
        ],
        "operationId": "listSchedules",
        "summary": "List schedules",
        "description": "Up to 100 of your schedules.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Schedules.",
            "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": [
          "Scheduled captures"
        ],
        "operationId": "getSchedule",
        "summary": "Get a schedule",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "The schedule.",
            "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": [
          "Scheduled captures"
        ],
        "operationId": "updateSchedule",
        "summary": "Pause, resume or edit a schedule",
        "description": "Send status to pause or resume, and/or any ScheduleRequest field to edit. A top-level field you send replaces the stored one, including an explicit null (webhook_url, timezone and js_render are cleared that way); a field you leave out keeps its stored value; the result is validated again as a whole. `options` is the exception to field-by-field editing: it is replaced as a whole map, not merged key by key, so send the complete options you want (read them with GET first). An option you leave out of the map is removed. Resuming checks the active-schedule limit, clears the failure count and computes the next run from now.\n\nFrom a dashboard session a webhook_url cannot be set (400 WEBHOOK_REQUIRES_API_KEY); sending null or an empty string clears it. A schedule created from a dashboard session never had an API key to sign with, so editing it also drops any webhook_url stored on it earlier, and an API key cannot add one to it either (400 WEBHOOK_REQUIRES_API_KEY): the scheduler signs a schedule's webhooks with the webhook secret of the key the schedule was created with. To receive webhooks, create the schedule with an API key.\n\nChanging url, mode or js_render of an on_change schedule (or switching a schedule to or from on_change) clears its baseline (the stored probe hash and last capture), so the next run takes a fresh baseline capture instead of comparing the new target with the old one. The request body must be a JSON object (400 MALFORMED_REQUEST otherwise).",
        "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": "IANA time zone for cron. null or an empty string goes back to UTC."
                  },
                  "webhook_url": {
                    "type": "string",
                    "description": "Send null or an empty string to remove it. The URL must be https and must not point to a private, loopback, link-local, carrier-grade NAT or cloud-metadata address (the same rules as a capture URL; 422 SSRF_BLOCKED otherwise). Needs an API key on both sides: sent from a dashboard session, or sent with an API key for a schedule that was created in the dashboard, it is refused with 400 WEBHOOK_REQUIRES_API_KEY, because webhooks are signed with the webhook secret of the key the schedule was created with.",
                    "nullable": true
                  },
                  "js_render": {
                    "type": "boolean",
                    "nullable": true,
                    "description": "null means false."
                  },
                  "options": {
                    "allOf": [
                      {
                        "$ref": "#/components/schemas/CaptureOptions"
                      }
                    ],
                    "description": "Replaces the stored options as a whole (not merged key by key). Send the complete map; an option left out is removed. null resets the options to their defaults. options.webhook_url is never stored (see webhook_url)."
                  },
                  "max_capture_age_seconds": {
                    "type": "integer",
                    "nullable": true,
                    "minimum": 86400,
                    "maximum": 34560000,
                    "description": "on_change only: a full capture at least every N seconds even without a change (1 to 400 days, longer than interval_seconds). null removes it. Switching the mode to interval or cron drops it."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated schedule.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Schedule"
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST (including nothing to update), INVALID_URL, INVALID_CRON or INTERVAL_TOO_SHORT. WEBHOOK_REQUIRES_API_KEY: webhook_url sent from a dashboard session, or sent with an API key for a schedule created in the dashboard (it has no key whose webhook secret could sign the webhooks).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "SCHEDULE_LIMIT_REACHED or ON_CHANGE_REQUIRES_PLAN. EMAIL_NOT_VERIFIED: a dashboard session whose email address is not confirmed (resuming a paused schedule; pausing and editing stay open). PLAN_API_READ_ONLY: an API key of a Website Archive account, on every PATCH (pausing and editing included). PLAN_API_READ_ONLY with details.reason \"session_outside_dashboard\": a dashboard session of a Website Archive account whose request has no Origin header or one that is not a ProofSnap dashboard address (new work on that plan is created in the dashboard only; a soft gate, not a security boundary). PLAN_ARCHIVE_ONLY: resuming a schedule on the Archive only plan.",
            "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 or webhook_url targets a private, loopback, link-local or otherwise blocked address.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      },
      "delete": {
        "tags": [
          "Scheduled captures"
        ],
        "operationId": "deleteSchedule",
        "summary": "Delete a schedule",
        "description": "Stops future runs. Captures already taken are kept.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted.",
            "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: an API key of a Website Archive account (its key is read only; use the dashboard).",
            "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": [
          "Scheduled captures"
        ],
        "operationId": "listScheduleRuns",
        "summary": "List runs of a schedule",
        "description": "The 50 newest captures taken by this schedule (on_change checks that found no change are not listed).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Runs.",
            "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": [
          "Matters"
        ],
        "operationId": "listMatters",
        "summary": "List matters",
        "description": "Up to 200 matters you are a member of. Matters of a company you have left are not listed.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Matters.",
            "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: the plan is not a Legal plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      },
      "post": {
        "tags": [
          "Matters"
        ],
        "operationId": "createMatter",
        "summary": "Create a matter",
        "description": "You become its owner. The custody log starts with a matter.created entry. Text fields are trimmed and shortened to 200 characters.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 200,
                    "description": "Plain text: whitespace is collapsed and trimmed, at most 200 characters, no control, text-direction (bidi) or invisible characters (the joiners U+200C and U+200D, which Hindi and Persian spelling needs, are allowed) (400 MALFORMED_REQUEST otherwise). Required. Shown on the public matter verification page."
                  },
                  "client": {
                    "type": "string",
                    "maxLength": 200,
                    "description": "Plain text: whitespace is collapsed and trimmed, at most 200 characters, no control, text-direction (bidi) or invisible characters (the joiners U+200C and U+200D, which Hindi and Persian spelling needs, are allowed) (400 MALFORMED_REQUEST otherwise). Blank or null means not set (stored as null). A value that is not a string is refused.",
                    "nullable": true
                  },
                  "case_number": {
                    "type": "string",
                    "maxLength": 200,
                    "description": "Plain text: whitespace is collapsed and trimmed, at most 200 characters, no control, text-direction (bidi) or invisible characters (the joiners U+200C and U+200D, which Hindi and Persian spelling needs, are allowed) (400 MALFORMED_REQUEST otherwise). Blank or null means not set (stored as null). A value that is not a string is refused. Shown on the public matter verification page.",
                    "nullable": true
                  },
                  "opposing_party": {
                    "type": "string",
                    "maxLength": 200,
                    "description": "Plain text: whitespace is collapsed and trimmed, at most 200 characters, no control, text-direction (bidi) or invisible characters (the joiners U+200C and U+200D, which Hindi and Persian spelling needs, are allowed) (400 MALFORMED_REQUEST otherwise). Blank or null means not set (stored as null). A value that is not a string is refused.",
                    "nullable": true
                  },
                  "jurisdiction": {
                    "type": "string",
                    "description": "Free text. Plain text: whitespace is collapsed and trimmed, at most 200 characters, no control, text-direction (bidi) or invisible characters (the joiners U+200C and U+200D, which Hindi and Persian spelling needs, are allowed) (400 MALFORMED_REQUEST otherwise). Blank or null means not set (stored as null). A value that is not a string is refused. Shown on the public matter verification page.",
                    "maxLength": 200,
                    "nullable": true
                  }
                },
                "required": [
                  "name"
                ]
              },
              "example": {
                "name": "Smith v. Jones",
                "case_number": "2026-CV-0142"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Matter"
                }
              }
            }
          },
          "400": {
            "description": "MISSING_FIELD: name is missing or empty. MALFORMED_REQUEST: invalid JSON. MALFORMED_REQUEST: a text field that is not a string, longer than 200 characters or contains control or text-direction characters.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "PAYMENT_REQUIRED: live caller whose subscription (or company) is past due or unpaid."
          },
          "403": {
            "description": "MATTERS_NOT_ENABLED: the plan is not a Legal plan. EMAIL_NOT_VERIFIED: a dashboard session whose email address is not confirmed, or an API key of such an account.",
            "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": [
          "Matters"
        ],
        "operationId": "getMatter",
        "summary": "Get a matter",
        "description": "The matter with its captures (up to 500), its custody log and up to 100 access events. `members` gives the email of every `member_uids` entry and `actor_emails` maps the uids named in this matter's custody log and audit_stream (actors, and the target of `member.added` / `member.removed`, so removed members too) to their email; an email is null, or the uid is missing from the map, when it cannot be read. Only callers who can open the matter receive these fields; `/matter-verify` never carries emails. A caller who may revoke the public share link (the matter owner, the owner of an ended or archived company) also gets `share`: `{ share_url, created_at, expires_at }` of the active link (`expires_at` is always null: links work until revoked), or null when there is none; other members never receive the field. audit_stream entries carry source_ip and user_agent only for the matter owner; members receive the entries without these two fields.\n\nA matter of a company that has ended carries `read_only: true`.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "The matter.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Matter"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "members": {
                          "type": "array",
                          "description": "One entry per `member_uids` entry, in the same order.",
                          "items": {
                            "type": "object",
                            "properties": {
                              "uid": {
                                "type": "string"
                              },
                              "email": {
                                "type": "string",
                                "format": "email",
                                "nullable": true,
                                "description": "Null when the account cannot be read."
                              }
                            }
                          }
                        },
                        "actor_emails": {
                          "type": "object",
                          "description": "`{ uid: email }` for the people named in this matter's custody_log and audit_stream (the system actor excluded). A uid whose email cannot be read is absent.",
                          "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": "Only for a caller who may revoke the link. Null when no active link exists.",
                          "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": "Always null: share links do not expire."
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "MATTERS_NOT_ENABLED: the plan is not a Legal plan.",
            "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": [
          "Matters"
        ],
        "operationId": "updateMatter",
        "summary": "Update a matter",
        "description": "Owner only. Text fields are trimmed and shortened to 200 characters. name is required: when it is sent it must be a non-empty string, and an empty or whitespace-only name, null or a non-string is refused with 400 and nothing is changed. Members: add_member_email adds a person who already has a ProofSnap account and belongs to the matter's company ; no account, an unconfirmed address and an account outside the company get one and the same 404 NOT_FOUND; remove_member_uid removes a member (not the owner). When both are sent, only add_member_email is applied. A matter has at most 50 members (a full matter refuses before the address is looked up). Member changes are written to the custody log. A change of name, client, case_number, opposing_party, jurisdiction or status is written to the custody log as matter.updated; its target is a JSON string with fields (the changed field names) and, when status changed, the new status. Free-text values are not written into the hashed, public custody log: the old and new values go to the access log (audit_stream, entry matter.updated). On an archived matter a rename or add_member_email is refused with 409 MATTER_ARCHIVED unless the same request reopens it (status \"open\"); remove_member_uid and reopening still work. Each invite counts toward a daily limit of 50 per inviting owner (company invites from the dashboard and matter add_member_email together, refusals included, since each one looks an address up); at the limit the request returns 429 RATE_LIMITED. A server-side failure does not count.\n\nLegal hold: `{ \"legal_hold\": true|false }` alone sets the matter's legal hold (matter owner only) and applies it to its captures in the same request, with the effects of a capture hold (S3 Object Lock legal hold, custody entries capture.legal_hold_placed / capture.legal_hold_released, the capture event chain). The response is the matter plus `changed` (the flag changed), `captures_held` or `captures_released`, `skipped` ([{ capture_id, code }], e.g. a capture in the trash) and `truncated` (more than 1000 captures: send the same value again to continue). Captures assigned to the matter later while the hold is on are held when they are assigned. One rate-limit unit per request plus one per 10 captures changed.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "description": "Must contain at least one non-whitespace character. Leave it out to keep the current name. Same plain-text rule: whitespace collapsed, at most 200 characters, no control, text-direction or invisible characters (U+200C and U+200D are allowed).",
                    "maxLength": 200
                  },
                  "client": {
                    "type": "string",
                    "nullable": true,
                    "description": "Plain text: whitespace is collapsed and trimmed, at most 200 characters, no control, text-direction (bidi) or invisible characters (the joiners U+200C and U+200D, which Hindi and Persian spelling needs, are allowed) (400 MALFORMED_REQUEST otherwise). null or a whitespace-only string clears the field (stored as null). A value that is not a string is refused with 400.",
                    "maxLength": 200
                  },
                  "case_number": {
                    "type": "string",
                    "nullable": true,
                    "description": "Plain text: whitespace is collapsed and trimmed, at most 200 characters, no control, text-direction (bidi) or invisible characters (the joiners U+200C and U+200D, which Hindi and Persian spelling needs, are allowed) (400 MALFORMED_REQUEST otherwise). null or a whitespace-only string clears the field (stored as null). A value that is not a string is refused with 400.",
                    "maxLength": 200
                  },
                  "opposing_party": {
                    "type": "string",
                    "nullable": true,
                    "description": "Plain text: whitespace is collapsed and trimmed, at most 200 characters, no control, text-direction (bidi) or invisible characters (the joiners U+200C and U+200D, which Hindi and Persian spelling needs, are allowed) (400 MALFORMED_REQUEST otherwise). null or a whitespace-only string clears the field (stored as null). A value that is not a string is refused with 400.",
                    "maxLength": 200
                  },
                  "jurisdiction": {
                    "type": "string",
                    "nullable": true,
                    "description": "Plain text: whitespace is collapsed and trimmed, at most 200 characters, no control, text-direction (bidi) or invisible characters (the joiners U+200C and U+200D, which Hindi and Persian spelling needs, are allowed) (400 MALFORMED_REQUEST otherwise). null or a whitespace-only string clears the field (stored as null). A value that is not a string is refused with 400.",
                    "maxLength": 200
                  },
                  "status": {
                    "type": "string",
                    "description": "Use POST /matters/{matter_id}/seal to seal.",
                    "enum": [
                      "open",
                      "archived"
                    ]
                  },
                  "add_member_email": {
                    "type": "string",
                    "format": "email"
                  },
                  "remove_member_uid": {
                    "type": "string"
                  },
                  "legal_hold": {
                    "type": "boolean",
                    "description": "Matter legal hold, sent alone (combined with another field: 400). true holds every current capture of the matter and every capture assigned to it while the hold is on (legal_hold_source \"matter:<id>\"; a capture already held keeps its own hold). false releases only the holds the matter placed; a manual or bulk hold stays. Matter owner only. Not blocked by a past-due subscription. Placing needs a paid plan (403 LEGAL_HOLD_NOT_AVAILABLE, details.reason plan) and test-mode captures are skipped (code LEGAL_HOLD_NOT_AVAILABLE in `skipped`); false alone is accepted also after the Legal plan ended."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated matter.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Matter"
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST: name empty, whitespace only or not a string; a text field that is not a string, longer than 200 characters or contains control or text-direction characters; invalid status; nothing to update; already a member; or removing the owner.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "PAYMENT_REQUIRED: live caller whose subscription (or company) is past due or unpaid."
          },
          "403": {
            "description": "MATTERS_NOT_ENABLED, FORBIDDEN (not the owner), LEGAL_HOLD_NOT_AVAILABLE (legal_hold true without a paid plan), or EMAIL_NOT_VERIFIED: a dashboard session whose email address is not confirmed, or an API key of such an account.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "MATTER_NOT_FOUND, or NOT_FOUND (no eligible account for the email: none, unconfirmed or outside the company, one response for all; or remove_member_uid is not a member).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "MATTER_SEALED, or MATTER_ARCHIVED (rename or new member on an archived matter). COMPANY_READ_ONLY: the matter belongs to a company that has ended or is kept as a company archive (caller on a Legal plan; open, export and share revocation still work).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "RATE_LIMITED: the daily limit of 50 member invites per inviting owner was reached (company invites from the dashboard and matter add_member_email count together; details.daily_limit, details.action \"member_invite\"). Retry-After says when the UTC day ends.",
            "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": [
          "Matters"
        ],
        "operationId": "assignMatterCaptures",
        "summary": "Add or remove captures",
        "description": "Any member may call this, but only captures the caller created are moved; others are returned in skipped, as are captures already in another matter and captures already in (or, with unassign, not in) this matter. Entries that are not capture IDs are ignored and at most 100 are processed. Each change is written to the custody log. An archived matter takes no new captures (409 MATTER_ARCHIVED); unassign still works.",
        "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 removes the captures from the matter.",
                    "default": false
                  }
                },
                "required": [
                  "capture_ids"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Result.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "matter_id": {
                      "type": "string"
                    },
                    "assigned": {
                      "type": "integer",
                      "description": "Number of captures changed."
                    },
                    "skipped": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "unassign": {
                      "type": "boolean"
                    },
                    "legal_holds_placed": {
                      "type": "integer",
                      "description": "Present when the matter's legal hold is on: how many of the assigned captures it placed a hold on."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST: no valid capture ID.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "PAYMENT_REQUIRED: live caller whose subscription (or company) is past due or unpaid."
          },
          "403": {
            "description": "MATTERS_NOT_ENABLED, or EMAIL_NOT_VERIFIED: a dashboard session whose email address is not confirmed, or an API key of such an account.",
            "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, or MATTER_ARCHIVED (adding to an archived matter). COMPANY_READ_ONLY: the matter belongs to a company that has ended or is kept as a company archive (caller on a Legal plan; open, export and share revocation still work).",
            "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": [
          "Matters"
        ],
        "operationId": "sealMatter",
        "summary": "Seal a matter",
        "description": "Owner only. Anchors the current custody head hash with OpenTimestamps and, when the deployment has eIDAS configured and the caller is live (a live API key, or a dashboard session with billing linked), with an eIDAS timestamp (the seal's eIDAS timestamp is not billed; if it fails the seal proceeds without it). A test caller (test API key or sandbox session) is sealed with OpenTimestamps only and the seal records eidas_status not_obtained with eidas_reason test_key; the request takes no eIDAS option, so this is not an error. The matter then becomes read-only: no edits, no capture changes, and its captures cannot be deleted. A matter that still holds a capture in the trash is not sealed (409 MATTER_HAS_TRASHED_CAPTURES): restore the capture or remove it from the matter first.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Sealed.",
            "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: the matter has no custody head.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "PAYMENT_REQUIRED: live caller whose subscription (or company) is past due or unpaid."
          },
          "403": {
            "description": "MATTERS_NOT_ENABLED: the plan is not a Legal plan. FORBIDDEN: not the owner. EMAIL_NOT_VERIFIED: a dashboard session whose email address is not confirmed, or an API key of such an account.",
            "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 (already sealed), MATTER_CHANGED (the custody log changed during sealing; retry) or MATTER_HAS_TRASHED_CAPTURES (a capture of the matter is in the trash; restore it or remove it from the matter first). COMPANY_READ_ONLY: the matter belongs to a company that has ended or is kept as a company archive (caller on a Legal plan; open, export and share revocation still work).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "TIMESTAMP_SERVICE_UNAVAILABLE (the anchor failed, nothing changed; retry) or CONFIGURATION_ERROR.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "EIDAS_SEAL_RETRY: the eIDAS timestamp of the seal did not arrive in time (or is still being issued). Nothing was sealed. Retry after Retry-After seconds: the retry receives the timestamp already issued for this custody head, no second stamp. SERVICE_UNAVAILABLE: the gateway could not check the credential; retry with 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": [
          "Matters"
        ],
        "operationId": "exportMatter",
        "summary": "Export a matter",
        "description": "Owner only. Builds one ZIP with custody_report.pdf, custody_log.json, matter.json, README.txt, access_log.json (when there are access events) and the evidence package of each capture. With Bates numbering on (the default) it adds Bates-stamped copies of each evidence.pdf under exhibits/ (exhibits numbered from the oldest evidence) and, with formats.loadfile, a Concordance/Opticon load file. With formats.pdfa the filing copies are also converted to PDF/A when the converter is available (on a synchronous export only within a time budget); only the copies counted in pdfa_count are PDF/A, the others are PDFs that make no PDF/A claim, and README.txt mentions PDF/A only when pdfa_count is above 0.\n\nA matter with up to 20 captures is exported synchronously (200). A larger matter is exported in the background: the response is 202 with a job_id; poll GET /matters/{matter_id}/export?job_id=. Captures that do not fit the size limit (120 MB synchronous; 800 MB and 300 captures in the background) are listed in the reports but not bundled.\n\nWorks on open and sealed matters. An archived matter is refused with 409 MATTER_ARCHIVED (reopen it first), except for a caller who cannot reopen it: the owner of an ended company within its retention, and an Archive only account. Each export is recorded as export.generated in the access log. Each export counts toward a daily limit of 50 per user; a background export that fails gives its unit back.",
        "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": "Letters and digits only, uppercased, up to 12. Default: from the case number or the matter name."
                      },
                      "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": "Include the e-discovery load file (needs Bates numbering).",
                        "default": true
                      },
                      "pdfa": {
                        "type": "boolean",
                        "description": "Convert the Bates-stamped filing copies to PDF/A (needs Bates numbering). Best effort: a copy that is not converted stays a PDF without a PDF/A claim; pdfa_count in the response says how many were converted.",
                        "default": true
                      }
                    }
                  },
                  "export_locale": {
                    "type": "string",
                    "description": "Language of custody_report.pdf, the exhibit index and 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 (a hyphen is accepted for the underscore). Other values fall back to English."
                  },
                  "declaration": {
                    "type": "object",
                    "description": "Accepted for compatibility. Its text fields (name, title, party, case_caption, executed_location) follow the plain-text rule (see PlainText): a value with a forbidden character is refused with 400 MALFORMED_REQUEST. Over-long values are shortened (name, title, executed_location 120, party 160, case_caption 200 characters)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Export ready.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "matter_id": {
                      "type": "string"
                    },
                    "url": {
                      "type": "string",
                      "description": "Presigned download link, valid 24 hours.",
                      "format": "uri"
                    },
                    "expires_at": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "capture_count": {
                      "type": "integer"
                    },
                    "captures_included": {
                      "type": "integer"
                    },
                    "captures_skipped": {
                      "type": "integer",
                      "description": "Captures listed in the reports but not bundled because a size limit was reached."
                    },
                    "custody_events": {
                      "type": "integer"
                    },
                    "exhibits_count": {
                      "type": "integer",
                      "description": "Bates-stamped exhibit copies."
                    },
                    "bates_prefix": {
                      "type": "string",
                      "nullable": true
                    },
                    "searchable": {
                      "type": "boolean",
                      "description": "Always false: no text layer is added to the filing copies. Kept for a stable response shape."
                    },
                    "pdfa_count": {
                      "type": "integer",
                      "description": "Filing copies under exhibits/ that are PDF/A. 0 means no copy is PDF/A and none claims to be."
                    },
                    "loadfile": {
                      "type": "boolean"
                    },
                    "size_bytes": {
                      "type": "integer"
                    },
                    "generated_at": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          },
          "202": {
            "description": "Export started in the background.",
            "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": "Includes the /v1 stage."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST: an export option is invalid (details.reason).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "PAYMENT_REQUIRED: live caller whose subscription (or company) is past due or unpaid."
          },
          "403": {
            "description": "MATTERS_NOT_ENABLED: the plan is not a Legal plan. FORBIDDEN: not the owner. EMAIL_NOT_VERIFIED: a dashboard session whose email address is not confirmed, or an API key of such an account.",
            "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: reopen the matter (PATCH status \"open\") first.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "CONFIGURATION_ERROR: the export could not be built or started.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          },
          "504": {
            "description": "UPSTREAM_TIMEOUT: the synchronous export did not finish within about 25 s (details.reason). Retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "get": {
        "tags": [
          "Matters"
        ],
        "operationId": "getMatterExport",
        "summary": "Poll a background export",
        "description": "Owner only. A job still processing 30 minutes after it started is reported as failed with error \"export timed out\". A job ends once: a job reported failed never turns ready later, and a ready job is never reported failed.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "job_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Job status. When ready, url and the counts are filled in.",
            "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: job_id missing.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "MATTERS_NOT_ENABLED: the plan is not a Legal plan. FORBIDDEN: not the owner.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "MATTER_NOT_FOUND, or NOT_FOUND for an unknown job.",
            "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": [
          "Matters"
        ],
        "operationId": "shareMatter",
        "summary": "Create a public verification link",
        "description": "Owner only. Returns a link anyone can open to see the matter's name, case number, jurisdiction, status, custody log and seal (see GET /matter-verify). No capture content is shared. Calling it again returns the same link. The link works until you revoke it.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Share link.",
            "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: live caller whose subscription (or company) is past due or unpaid."
          },
          "403": {
            "description": "MATTERS_NOT_ENABLED: the plan is not a Legal plan. FORBIDDEN: not the owner. EMAIL_NOT_VERIFIED: a dashboard session whose email address is not confirmed, or an API key of such an account.",
            "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: an archived matter is not shared. COMPANY_READ_ONLY: the matter belongs to a company that has ended or is kept as a company archive (caller on a Legal plan; open, export and share revocation still work).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      },
      "delete": {
        "tags": [
          "Matters"
        ],
        "operationId": "revokeMatterShare",
        "summary": "Revoke the public link",
        "description": "Owner only. The link stops working. A later POST creates a new one. The owner of an ended company (or one kept as a company archive) and the owner of a matter on the Archive only plan may revoke the link without a Legal plan (creating one stays a Legal plan feature).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Revoked.",
            "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: live caller whose subscription (or company) is past due or unpaid."
          },
          "403": {
            "description": "MATTERS_NOT_ENABLED, FORBIDDEN (not the owner), or EMAIL_NOT_VERIFIED: a dashboard session whose email address is not confirmed, or an API key of such an account.",
            "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": [
          "Public"
        ],
        "operationId": "health",
        "summary": "Health check",
        "description": "Always 200 while the API is responding. With deep=1 (or true) the response adds dependencies: whether each downstream service is configured, and for eIDAS a live probe of the timestamp service. Only eIDAS is probed; the others report configuration only. status becomes degraded when eIDAS is configured and its probe fails. A probe that does not answer in time is reported in dependencies (probe_timeout, or eidas.error timeout), never as a failed /health. deep=1 is rate limited per client IP (10 a minute): over it the request returns 429 RATE_LIMITED with Retry-After.",
        "security": [],
        "parameters": [
          {
            "name": "deep",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "1",
                "true"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Health.",
            "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": "Milliseconds since the serving instance started."
                    },
                    "version": {
                      "type": "string"
                    },
                    "dependencies": {
                      "type": "object",
                      "properties": {
                        "eidas": {
                          "type": "object",
                          "properties": {
                            "configured": {
                              "type": "boolean"
                            },
                            "healthy": {
                              "type": "boolean",
                              "description": "null when not configured.",
                              "nullable": true
                            },
                            "error": {
                              "type": "string",
                              "description": "Fixed code when the eIDAS probe did not report healthy: timeout (no answer within 15 s), unavailable (the timestamp service answered unhealthy), error (the probe could not be run). Details are logged server side, never returned.",
                              "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": "Only when the dependency check itself failed: probe_timeout when it did not answer within 20 s, probe_failed otherwise. The response is still 200."
                        }
                      },
                      "description": "Only with deep=1."
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/ledger/{hash}": {
      "get": {
        "tags": [
          "Public"
        ],
        "operationId": "ledgerLookup",
        "summary": "Look up a manifest hash",
        "description": "Checks whether ProofSnap recorded issuing a package whose manifest.json has this SHA-256. Captures, certificates, comparisons and redactions are recorded when they are issued, with the hash of the manifest that ships (after the qualified timestamp step); packages issued before the ledger existed are not in it. issued: false means only that there is no record.",
        "security": [],
        "parameters": [
          {
            "name": "hash",
            "in": "path",
            "required": true,
            "description": "SHA-256 of manifest.json, 64 hex characters, any case, optionally prefixed sha256:.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lookup result.",
            "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": "Absent when issued is false."
                    },
                    "type": {
                      "type": "string",
                      "enum": [
                        "capture",
                        "certify",
                        "compare",
                        "redaction"
                      ],
                      "nullable": true,
                      "description": "Absent when issued is false."
                    },
                    "eidas": {
                      "type": "boolean",
                      "nullable": true,
                      "description": "Whether the issued package carries a qualified eIDAS timestamp (manifest.json.tsr). Absent when issued is false; null for records written before 2026-10-01."
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "NOT_FOUND: the hash is not 64 hex characters.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/matter-verify": {
      "get": {
        "tags": [
          "Public"
        ],
        "operationId": "matterVerify",
        "summary": "Public view of a shared matter",
        "description": "Read-only data behind a matter share link: matter details, the custody log (so the hash chain can be recomputed) and the seal with its OpenTimestamps proof and eIDAS token. No capture content, download links or access log. attested_by.organization is the owner-set name of the company the matter belongs to (a company matter), or null; a personal matter names no organisation. custody_log[].actor_org is the company the actor belonged to when that entry was written (recorded with the entry, outside the hashed payload), only on a company matter; entries written before that was recorded show null. Rate limited per client IP (30 requests per 60 seconds by default).",
        "security": [],
        "parameters": [
          {
            "name": "token",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z0-9_-]{16,64}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Matter view.",
            "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 on seals made before 2026-10-01 without an eIDAS token."
                            },
                            "eidas_reason": {
                              "type": "string",
                              "enum": [
                                "token_refused",
                                "unavailable",
                                "test_key",
                                "account_limit"
                              ],
                              "description": "Only when eidas_status is not_obtained. test_key: the seal was made by a test caller, so no eIDAS timestamp was requested. account_limit: the account has no paid invoice yet and reached its eIDAS limit (20 a UTC day, 50 a UTC month), so no eIDAS timestamp was requested."
                            },
                            "eidas_verification_status": {
                              "type": "string",
                              "nullable": true,
                              "description": "verified when the token passed the ProofSnap check at sealing."
                            },
                            "eidas_revocation_status": {
                              "type": "string",
                              "nullable": true,
                              "enum": [
                                "good",
                                "revoked_after_gen_time",
                                "unknown",
                                "not_checked",
                                null
                              ],
                              "description": "TSA certificate status established at sealing (not fetched live)."
                            },
                            "eidas_validation": {
                              "type": "object",
                              "nullable": true,
                              "additionalProperties": true,
                              "description": "The eidas_validation.json content recorded at sealing (TSA chain, pinned root, OCSP response, CRL, verification record) so the token in tsr_base64 can be checked offline. null on older seals."
                            }
                          },
                          "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": "Always null."
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "NOT_FOUND: invalid, unknown or revoked token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/captures/export-jobs": {
      "post": {
        "tags": [
          "Captures"
        ],
        "operationId": "startCaptureExportJob",
        "summary": "Export all captures as one download (async job)",
        "description": "Starts a job that packs all your captures into one download, split into parts of at most about 2 GB. Each part is a plain ZIP: README.txt (English) plus README.<language>.txt (the same text translated, when language is not en; both UTF-8 with a byte order mark and CRLF line endings; line 1 of each points to the other), captures.csv (UTF-8 with a byte order mark, CRLF; every capture of the export with the columns member (the author's email in a company export, empty in your own export), capture_id, created_at (when the request was received), captured_at (the moment of capture; empty for a failed capture), url, status (completed, failed or unavailable), export_part, file, ots_file (captures/<capture_id>.upgraded.ots when that part holds the proof, else empty), test_mode (true for a capture made with a test key: watermarked, not evidence) and note (why a package is not included, also for every unavailable row); the same list in every part, and when parts disagree the list of the highest part is the final one) and captures/<capture_id>.zip, the original sealed evidence package byte for byte, plus captures/<capture_id>.upgraded.ots when an upgraded OpenTimestamps proof is stored. Each inner package is verified on its own with its verification/verify.sh or verify.ps1. A package larger than 2 GB is not included (listed unavailable with a note; download it on its own with GET /captures/{capture_id}). Failed captures are listed in captures.csv only; trashed and erased captures are not exported. The entries of every part are dated with the export time (UTC, also as the extended timestamp field). Poll GET /captures/export-jobs/{job_id} until status is ready. **Reuse:** when your last export is ready, has the same scope (and org) and language, at least a day of its download window left, your rights are unchanged and no capture was added, completed, trashed, restored or erased since it was planned, the request returns 200 with that job (reused: true): nothing is rebuilt and no daily unit is used. A newer ready export of the same scope replaces the previous one (the previous job becomes expired, expired_reason superseded). Who may start it: a live API key or the dashboard session, every plan including sandbox, Archive only and read-only (Website Archive) keys. A test key gets 403 FORBIDDEN (the export holds live captures), and GET /captures/export-jobs/{job_id} returns 404 EXPORT_JOB_NOT_FOUND to a test key for every job. Your own export (scope own) holds your personal captures and the ones you made for your current company or a company you own, also through a key while that company is a running company archive (the other seats' captures of an archive are reached through the dashboard session only). scope company (the company owner only, also for an ended company within its download window; a running company archive through the dashboard session only) exports every seat's captures made for the company plus your own, with the author's email in the member column. One running job per account (409 EXPORT_JOB_RUNNING names it), 5 started jobs per account and UTC day (a failed job gives its unit back, a reused one uses none), the per-minute rate limit of your plan. At most the newest 20,000 captures per job (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": "With scope company: one of the companies you own (current or last), when you own both."
                  },
                  "language": {
                    "type": "string",
                    "default": "en",
                    "description": "Language of the README translation: README.<language>.txt (the full translation) next to the English README.txt in every part; en gives README.txt only. One of: 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. Case, hyphen and region are tolerated (\"PT-BR\" is pt_BR); bare \"pt\" and \"zh\" and anything else are 400 MALFORMED_REQUEST. A ready export is reused only for the same language. The dashboard sends its UI language."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Your ready export is still current and is reused: no new job, no daily unit. Read its links with 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": "The job was started.",
            "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: the body is not a JSON object, or scope / org is invalid.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "FORBIDDEN: the request was made with a test key, or scope company by someone who is not the owner of a company within its download window.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "EXPORT_JOB_RUNNING: a job of the account is still processing; details.job_id and details.poll_path name it.",
            "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": [
          "Captures"
        ],
        "operationId": "getCaptureExportJob",
        "summary": "Status and download links of a capture export job",
        "description": "The job's status and progress; once ready, one presigned link per part. A link is valid until url_expires_at: at most 24 hours, never past download_expires_at, and never past the credentials that signed it (it can be less than an hour); poll again after url_expires_at for fresh links. download_expires_at is 7 days after the UTC midnight before the job's first part was started (6 to 7 days after the job became ready); the parts are deleted after it. The job loses its download early (status expired, the parts deleted, expired_reason) when a newer export of the same scope replaced it, a capture it holds was erased, its company's data was deleted, or your rights no longer cover it (checked on every read of a ready job). A job that reports no progress for 30 minutes is ended as failed by this call and what it wrote is deleted. The first response with links in an hour is recorded in the audit log (capture.export_job.links_issued). Only the account that started the job sees it; a company export that holds a company archive only through the dashboard session.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "job_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^cexp_[A-Za-z0-9]{16}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The job.",
            "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 while the plan and the parts are written; ready with the download links; failed (start a new job, the day's unit was given back); expired when the download window is over or the download was taken away (expired_reason; the parts are deleted)."
                    },
                    "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": "The README language of the job (README.<language>.txt besides README.txt; en for README.txt only)."
                    },
                    "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 when the account has more than 20,000 exportable captures: the job holds the newest 20,000."
                    },
                    "counts": {
                      "type": "object",
                      "nullable": true,
                      "description": "Set when the job is ready.",
                      "properties": {
                        "captures": {
                          "type": "integer",
                          "description": "Rows of captures.csv."
                        },
                        "packaged": {
                          "type": "integer",
                          "description": "Packages in the parts."
                        },
                        "failed": {
                          "type": "integer",
                          "description": "Failed captures (no package, listed in captures.csv only)."
                        },
                        "unavailable": {
                          "type": "integer",
                          "description": "Packages not included: they could not be read while the export ran, or are larger than 2 GB (captures.csv notes which)."
                        }
                      }
                    },
                    "parts": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": [
                          "part",
                          "bytes",
                          "count",
                          "download_url"
                        ],
                        "properties": {
                          "part": {
                            "type": "integer",
                            "minimum": 1,
                            "example": 1,
                            "description": "Part number, 1 to the number of parts."
                          },
                          "bytes": {
                            "type": "integer",
                            "example": 1834201112,
                            "description": "Size of the part ZIP in bytes."
                          },
                          "count": {
                            "type": "integer",
                            "example": 412,
                            "description": "Capture packages in this part."
                          },
                          "filename": {
                            "type": "string",
                            "example": "proofsnap-captures-2026-10-07T1430Z-part-1-of-3.zip",
                            "description": "File name the download is saved under (Content-Disposition): proofsnap-captures-<UTC date and time the job started>, with -part-N-of-M when there are several parts."
                          },
                          "download_url": {
                            "type": "string",
                            "format": "uri",
                            "nullable": true,
                            "description": "Presigned GET of the part ZIP, valid until url_expires_at. null when the job has expired."
                          }
                        }
                      }
                    },
                    "download_expires_at": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true,
                      "description": "End of the download window: 7 days after the UTC midnight before the first part was started (the storage lifecycle deletes the parts after it)."
                    },
                    "url_expires_at": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true,
                      "description": "When the download_url values of this response stop working: at most 24 hours, never past download_expires_at, never past the signing credentials. Poll again after it for fresh links."
                    },
                    "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": "Set when status is expired: window_ended (download_expires_at passed), superseded (a newer export of the same scope), rights_changed (your rights no longer cover the job), capture_erased (a capture it holds was erased), company_deleted (its company's data was deleted)."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "EXPORT_JOB_NOT_FOUND: no such job, another account's job, its record expired (8 days after it ended), or the request was made with a test key.",
            "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": "List webhook subscriptions",
        "description": "The account's webhook subscriptions, newest first, active and disabled, without secrets. API key or dashboard session; a read-only key (Website Archive) may use it.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Subscriptions.",
            "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": "Subscribe to account events",
        "description": "Subscribe an https endpoint to account events: every matching event of the account is POSTed to it, signed with the subscription's own secret (returned once in this response). Needs a confirmed email address (403 EMAIL_NOT_VERIFIED). Allowed while a subscription is past due (nothing is billed). Limits of ACTIVE subscriptions per account: 25 on developer, startup, growth, custom, legal_solo and legal_seats, 10 on website_archive, 0 on archive_only and sandbox (409 WEBHOOK_SUBSCRIPTION_LIMIT). The URL follows the webhook_url rules (https, no credentials, no private or reserved address; checked again at every delivery). Idempotency-Key is supported: a retry with the same key and body within 24 hours gets the first response back, secret included, instead of a second subscription. API key or dashboard session; a read-only key (Website Archive) may use it.",
        "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": "Plain text, at most 200 characters."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created. `secret` is shown only here.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookSubscriptionCreated"
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST (events, filters, description, unknown field), MISSING_FIELD (url) or 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 (the URL points to a blocked address) or 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": "Get a webhook subscription",
        "description": "One subscription with its delivery state, without the secret. API key or dashboard session; a read-only key (Website Archive) may use it.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "The subscription.",
            "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: unknown, deleted, or another account's.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      },
      "patch": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "updateWebhookSubscription",
        "summary": "Change or reactivate a webhook subscription",
        "description": "Change events, filters (null or {} clears them) or description, or reactivate a disabled subscription with status \"active\" (resets the failure counters; checked against the active limit). The URL cannot be changed: create a new subscription. Needs a confirmed email address. API key or dashboard session; a read-only key (Website Archive) may use it.",
        "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": "The changed subscription.",
            "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: unknown, deleted, or another account's.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "WEBHOOK_SUBSCRIPTION_LIMIT (reactivation above the limit).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      },
      "delete": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "deleteWebhookSubscription",
        "summary": "Delete a webhook subscription",
        "description": "Deletes the subscription, its secret and its queued deliveries. 404 for an unknown or already deleted one (treat it as gone). API key or dashboard session; a read-only key (Website Archive) may use it.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "204": {
            "description": "Deleted.",
            "headers": {
              "X-Request-ID": {
                "$ref": "#/components/headers/XRequestId"
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "WEBHOOK_SUBSCRIPTION_NOT_FOUND: unknown, deleted, or another account's.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/events": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "listEvents",
        "summary": "Event log",
        "description": "Every event emitted for the account in the last 30 days, whether or not a subscription received it, newest first. An API key sees the events of the environment it runs in (a live key live events, a test key test events); a dashboard session sees live events. Use it to backfill or to poll instead of subscribing. API key or dashboard session; a read-only key (Website Archive) may use it.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "type",
            "in": "query",
            "required": false,
            "description": "Only this event type.",
            "schema": {
              "$ref": "#/components/schemas/WebhookEventType"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "1 to 100, default 25.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            }
          },
          {
            "name": "starting_after",
            "in": "query",
            "required": false,
            "description": "An event id: the page starts after it (pass next_cursor). An id of another account or environment, or older than 30 days, is 400.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of events.",
            "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": "Pass as starting_after for the next page; null on the last page."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST (type, limit or 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": "Who am I",
        "description": "What the credential is: plan, environment, whether the API key is read only, and the key's id, name and prefix (null for a dashboard session). Integrations use it to test a connection and to label it. No email address and no account id. API key or dashboard session; a read-only key (Website Archive) may use it.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "The credential.",
            "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 for an API key of a Website Archive account (it may read and subscribe, not create captures)."
                    },
                    "env": {
                      "type": "string",
                      "enum": [
                        "live",
                        "test"
                      ],
                      "description": "The environment the credential runs in."
                    },
                    "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": [
          "Captures"
        ],
        "operationId": "listCaptureEvents",
        "summary": "Capture event chain (deletions and legal holds)",
        "description": "The account's tamper-evident record of what happened to its captures: trash, restore, permanent deletion (erase), the trash purge, retention deletion, deletion with the account or the company, legal hold placed and released (a release by the system after the subscription ended has actor `system` and detail `subscription_ended`), the S3 Object Lock result, and the import of an evidence package (type `import`, detail = the package kind). Oldest first. The chain outlives the captures and the account's data: it holds ids and manifest hashes only, no capture content and no free text.\n\nVerify it: (1) for each event compute SHA-256 over the canonical JSON of the hashed fields (verification.hashed_fields; keys sorted, no whitespace, absent values as null) and compare it with `hash`; (2) check that each `prev_hash` equals the previous event's `hash` (64 zeros for seq 1) and that seq has no gaps; (3) for an anchor, check that `head_hash` equals the `hash` of event `to_seq`, write `ots_proof_base64` to a file (base64-decoded) and run `ots verify -d <head_hash> anchor.ots` (OpenTimestamps client): it proves that the chain up to that event existed no later than the Bitcoin block. Anchors are made once a day for every account with events of an earlier day.\n\nAPI key or dashboard session, the own account; `scope=company` gives a company owner the chain of the company's captures (`org` picks one when the caller owns several; a company kept as a company archive through the dashboard session only). A test key gets 403 FORBIDDEN: the chain covers live captures and cannot be filtered without breaking it.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "1 to 500, default 100.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 100
            }
          },
          {
            "name": "starting_after",
            "in": "query",
            "required": false,
            "description": "An event seq: the page starts after it (pass 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: the company id.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of the chain.",
            "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 or scope invalid.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "FORBIDDEN: the request was made with a test key, or scope=company by someone who does not own the company.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/legal-holds": {
      "post": {
        "tags": [
          "Captures"
        ],
        "operationId": "bulkLegalHold",
        "summary": "Place or release legal holds in bulk",
        "description": "Places (enabled true) or releases (false) the legal hold of many captures at once. selector has exactly one of: `capture_ids` (1 to 500 ids), `matter_id` (the captures of a matter the caller can open), `host` (the caller's own captures of that hostname) or `schedule_id` (the caller's own captures made by one of the caller's schedules). Each capture goes through the rules of PATCH /captures/{capture_id} with action legal_hold and gets its own result: applied, unchanged (already in that state), or refused with the error code (e.g. FORBIDDEN, CAPTURE_IN_TRASH, CAPTURE_PROCESSING, LEGAL_HOLD_OWNER_ONLY, LEGAL_HOLD_MATTER, LEGAL_HOLD_SOURCE for a redaction whose source capture is still held, CAPTURE_NOT_FOUND, LEGAL_HOLD_NOT_AVAILABLE for a test-mode capture). The redactions made from each capture follow its hold, also for a capture reported as unchanged; when that does not finish within the request, it is completed within minutes. Placing holds needs a paid plan: the free sandbox gets 403 LEGAL_HOLD_NOT_AVAILABLE (details.reason \"plan\") for the whole request; releasing works on every plan. A hold placed here has legal_hold_source \"bulk\"; releasing it in bulk or one by one works the same. At most 500 captures per request: a matter, host or schedule with more is cut (`truncated: true`, send it again). The S3 Object Lock legal hold of the first 25 changed captures is applied in the request, the rest within minutes (`s3_legal_hold.status` pending). Cost: one rate-limit unit per 10 captures (at least one); a request needing more than one window allows is 400 RATE_LIMIT_COST_TOO_HIGH. API key or dashboard session; on Website Archive the API key is read only (403 PLAN_API_READ_ONLY). A dashboard session or key whose email address is not confirmed gets 403 EMAIL_NOT_VERIFIED. Not blocked by a past-due subscription.",
        "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": "Plain text kept with each hold placed (not with a release); never in the hashed chains."
                  },
                  "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": "Per-capture results.",
            "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": "The error code of a refusal."
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST (body, selector, more than 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 (placing without a paid plan, details.reason plan).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "MATTER_NOT_FOUND (matter_id the caller cannot open), 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": [
          "Package import"
        ],
        "operationId": "createImport",
        "summary": "Start a package import",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "description": "Creates an import and starts its upload. Send the exact size of the ZIP in bytes; the response contains one presigned PUT URL per 32 MiB part (the last one smaller), valid for 1 hour (`upload.expires_at`; never longer than the server's signing credentials). PUT each part's bytes to its URL, keep each response's `ETag` header, then call POST /imports/{import_id}/complete within 24 hours. With `matter_id` the accepted capture is added to that matter: you must be able to add captures to it now (an open matter you can open; a company seat's import only to a matter of that company). Every create counts towards the daily limit of 50.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "filename",
                  "size_bytes"
                ],
                "additionalProperties": false,
                "properties": {
                  "filename": {
                    "type": "string",
                    "maxLength": 200,
                    "description": "The file name to show (plain text: no control, invisible or text-direction characters)."
                  },
                  "size_bytes": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 2147483648,
                    "description": "Exact size of the ZIP in bytes, at most 2 GiB."
                  },
                  "matter_id": {
                    "type": "string",
                    "description": "Optional: add the accepted capture to this matter."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Import created; upload the parts.",
            "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": "Presigned S3 UploadPart URL: PUT exactly size_bytes bytes (the URL signs the Content-Length), read the ETag response header. No other header is needed."
                              },
                              "size_bytes": {
                                "type": "integer",
                                "description": "The exact byte length of this part: part_size for every part but the last, the rest for the last one (part n starts at (n - 1) x part_size)."
                              }
                            }
                          }
                        },
                        "expires_at": {
                          "type": "string",
                          "format": "date-time"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "MALFORMED_REQUEST (body, filename, size_bytes, matter_id, unknown field).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "PAYMENT_REQUIRED: the subscription is past due or unpaid.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "IMPORT_NOT_ALLOWED (details.reason plan: Legal plans only; live_credentials_required: a test key, a session without linked billing or the sandbox), EMAIL_NOT_VERIFIED, FORBIDDEN (a company seat's import to a matter of another company).",
            "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 imports in progress; 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 above 2 GiB (details.max_bytes).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "RATE_LIMITED: the per-minute limit, or the daily limit of 50 imports (details.action import_create, details.daily_limit).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      },
      "get": {
        "tags": [
          "Package import"
        ],
        "operationId": "listImports",
        "summary": "List your imports",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "description": "Your imports, newest first. Final imports are kept for 30 days (an accepted import's capture stays). An upload past its 24 hours is shown as 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 of the previous page."
          }
        ],
        "responses": {
          "200": {
            "description": "A page of imports.",
            "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": "Import ID (imp_ followed by 16 to 24 letters and digits). A malformed ID returns 404 IMPORT_NOT_FOUND.",
          "schema": {
            "type": "string",
            "pattern": "^imp_[A-Za-z0-9]{16,24}$"
          }
        }
      ],
      "get": {
        "tags": [
          "Package import"
        ],
        "operationId": "getImport",
        "summary": "Get an import",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "description": "The import and, once final, its result. Poll it after POST /imports/{import_id}/complete; verifying usually takes seconds, a package of 2 GiB up to a few minutes. With ?upload=true on an upload still in progress the response adds upload: fresh presigned URLs (parts) for the parts storage does not hold yet and the parts already received (received: part_number, etag, size_bytes; null when they could not be listed, then every part is presigned again), or completed: true when the upload was already completed (send complete again). Use it to resume after a network drop, an expired part URL (they last at most 1 hour, the upload window 24 hours) or a page reload. ?upload=true costs one unit of the per-minute rate limit.",
        "responses": {
          "200": {
            "description": "The import.",
            "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 (also another account's import).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "RATE_LIMITED (only with ?upload=true).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        },
        "parameters": [
          {
            "name": "upload",
            "in": "query",
            "required": false,
            "description": "true (or 1): add fresh part URLs and the received parts of an upload in progress.",
            "schema": {
              "type": "string",
              "enum": [
                "true",
                "1"
              ]
            }
          }
        ]
      },
      "delete": {
        "tags": [
          "Package import"
        ],
        "operationId": "cancelImport",
        "summary": "Cancel an upload",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "description": "Cancels an import that is still uploading: the upload is aborted, the parts deleted and the import removed (GET returns 404 afterwards).",
        "responses": {
          "200": {
            "description": "Canceled.",
            "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: the import is no longer uploading (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": "Import ID (imp_ followed by 16 to 24 letters and digits). A malformed ID returns 404 IMPORT_NOT_FOUND.",
          "schema": {
            "type": "string",
            "pattern": "^imp_[A-Za-z0-9]{16,24}$"
          }
        }
      ],
      "post": {
        "tags": [
          "Package import"
        ],
        "operationId": "completeImport",
        "summary": "Complete the upload and verify",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "description": "Completes the upload and starts the verification. parts is optional: send every part number (1 to N, in order) with the ETag the part's PUT returned, or leave it out and the server reads the uploaded parts from storage (useful in a browser that cannot read the ETag header). Checks that the stored size equals size_bytes. Poll GET /imports/{import_id}. A part list the storage refuses, or a part not uploaded yet (details.missing_parts), is 400 and the upload stays open: upload what is missing (GET /imports/{import_id}?upload=true gives fresh URLs) and send it again. Sending complete again after a response was lost is safe: a complete that is still running returns 409, one that died is taken over after 2 minutes, and an upload that was already completed goes on to the verification. A size that differs from size_bytes discards the upload (422 IMPORT_SIZE_MISMATCH; the import is rejected, create a new one).",
        "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": "The ETag response header of the part's PUT, quotes included or not."
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Verification started.",
            "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 (parts out of order or not matching the uploaded parts; or, without parts, a part not uploaded yet: 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: the import is not uploading any more (details.status, for example verifying or 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": "Send your API key as `Authorization: Bearer ps_live_...` (the scheme name is case-insensitive). A missing or malformed header returns 401 MISSING_API_KEY. An unknown, revoked or expired key, or an account without API access, returns 401 INVALID_API_KEY with `WWW-Authenticate: Bearer realm=\"ProofSnap API\", error=\"invalid_token\"`. 403 is reserved for a valid credential that is not allowed to do something (FORBIDDEN, plan gates). A `ps_live_` key of an account without a linked billing account (Stripe customer) runs as a test key: captures are watermarked and never billed, and include_eidas is refused with 403 EIDAS_REQUIRES_LIVE_KEY. The gateway caches the decision for a key for up to 60 seconds, so a revoked key can keep working for up to 60 seconds. If the credential cannot be checked because of a temporary backend failure, the gateway returns 503 SERVICE_UNAVAILABLE instead; that response is not cached, so retry with backoff. The `WWW-Authenticate` challenge is returned unchanged on a 401 from the gateway (the credential check); on a 401 from a route handler the gateway delivers the same challenge renamed to `x-amzn-Remapped-WWW-Authenticate`."
      }
    },
    "headers": {
      "XRequestId": {
        "description": "Request ID, also useful in support requests. Sent on success responses; error bodies carry it as request_id.",
        "schema": {
          "type": "string"
        }
      },
      "RateLimitLimit": {
        "description": "Requests allowed in the current 60-second window. Legacy header, kept next to RateLimit / RateLimit-Policy.",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimitRemaining": {
        "description": "Requests left in the current window. Legacy header, kept next to RateLimit / RateLimit-Policy.",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimitReset": {
        "description": "ISO 8601 time at which the current window ends. Legacy header, kept next to RateLimit / RateLimit-Policy.",
        "schema": {
          "type": "string",
          "format": "date-time"
        }
      },
      "RetryAfter": {
        "description": "Seconds to wait before retrying.",
        "schema": {
          "type": "integer"
        }
      },
      "QuotaLimit": {
        "description": "Captures included in the plan per month (live keys on plans with a monthly quota).",
        "schema": {
          "type": "integer"
        }
      },
      "QuotaRemaining": {
        "description": "Included captures left this month.",
        "schema": {
          "type": "integer"
        }
      },
      "QuotaUsed": {
        "description": "Completed captures this month.",
        "schema": {
          "type": "integer"
        }
      },
      "QuotaCap": {
        "description": "The monthly maximum that applies to the caller (plan default, own cap or company limit).",
        "schema": {
          "type": "integer"
        }
      },
      "QuotaOrgLimit": {
        "description": "Company members only: the company-wide monthly limit, when the owner set one.",
        "schema": {
          "type": "integer"
        }
      },
      "QuotaOrgUsed": {
        "description": "Company members only: captures admitted for the whole company this month.",
        "schema": {
          "type": "integer"
        }
      },
      "SandboxLimit": {
        "description": "Sandbox (dashboard session without a plan): lifetime allowance of watermarked test captures (50).",
        "schema": {
          "type": "integer"
        }
      },
      "SandboxRemaining": {
        "description": "Sandbox: allowance left.",
        "schema": {
          "type": "integer"
        }
      },
      "SandboxUsed": {
        "description": "Sandbox: allowance used.",
        "schema": {
          "type": "integer"
        }
      },
      "IdempotentReplay": {
        "description": "\"true\" when this response replays an earlier request with the same Idempotency-Key.",
        "schema": {
          "type": "string",
          "enum": [
            "true"
          ]
        }
      },
      "WWWAuthenticate": {
        "description": "RFC 6750 challenge sent with every 401. On a 401 returned by a route handler rather than by the gateway, API Gateway delivers it renamed to `x-amzn-Remapped-WWW-Authenticate`. An invalid, revoked or expired API key, or a bad or revoked session token (INVALID_API_KEY): `Bearer realm=\"ProofSnap API\", error=\"invalid_token\"`. A missing or malformed Authorization header (MISSING_API_KEY): `Bearer realm=\"ProofSnap API\"`.",
        "schema": {
          "type": "string"
        },
        "example": "Bearer realm=\"ProofSnap API\", error=\"invalid_token\""
      },
      "RateLimitPolicy": {
        "description": "Rate-limit policy, following the IETF draft draft-ietf-httpapi-ratelimit-headers (structured-field form; draft based, it may change with the draft): `\"default\";q=<requests per window>;w=60`.",
        "schema": {
          "type": "string"
        },
        "example": "\"default\";q=120;w=60"
      },
      "RateLimit": {
        "description": "Current rate-limit state, following the IETF draft draft-ietf-httpapi-ratelimit-headers (draft based): `\"default\";r=<requests left>;t=<seconds until the window resets>`. Sent on responses of rate-limited routes (captures single and batch, compare, redact, certify-hash) after the limiter runs, not only on 429 (a request refused before the limiter, for example for authentication, has none).",
        "schema": {
          "type": "string"
        },
        "example": "\"default\";r=117;t=42"
      }
    },
    "parameters": {
      "CaptureId": {
        "name": "capture_id",
        "in": "path",
        "required": true,
        "description": "Capture, certificate, comparison, redaction or imported package ID. IDs start with cap_, cert_, cmp_, red_ or imp_. A malformed ID returns 404 CAPTURE_NOT_FOUND.",
        "schema": {
          "type": "string",
          "example": "cap_a1B2c3D4e5F6g7H8"
        }
      },
      "ScheduleId": {
        "name": "schedule_id",
        "in": "path",
        "required": true,
        "description": "Schedule ID. A schedule of another account returns 404 SCHEDULE_NOT_FOUND.",
        "schema": {
          "type": "string",
          "example": "sch_a1B2c3D4e5F6g7H8"
        }
      },
      "MatterId": {
        "name": "matter_id",
        "in": "path",
        "required": true,
        "description": "Matter ID. A matter you are not a member of returns 404 MATTER_NOT_FOUND.",
        "schema": {
          "type": "string",
          "example": "mat_a1B2c3D4e5F6g7H8"
        }
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "Optional client key that makes a retry safe. Only the first 255 characters are used, and keys are scoped to your account for 24 hours. A retry with the same key and the same body returns the original response body with `Idempotent-Replay: true` (202 for a single capture, a batch or a comparison, 201 for a redaction; `status` is refreshed to the current status of the capture, comparison or redaction, and of each capture of a batch); no new capture is created or billed. The same key with a different body returns 422 IDEMPOTENCY_KEY_REUSED. A retry while the first request is still being accepted returns 409 IDEMPOTENCY_IN_PROGRESS with the original ID in `error.details.capture_id` (the batch_id for a batch).",
        "schema": {
          "type": "string",
          "maxLength": 255
        }
      },
      "SubscriptionId": {
        "name": "subscription_id",
        "in": "path",
        "required": true,
        "description": "Webhook subscription ID. A subscription of another account returns 404 WEBHOOK_SUBSCRIPTION_NOT_FOUND.",
        "schema": {
          "type": "string",
          "example": "whs_a1B2c3D4e5F6g7H8"
        }
      }
    },
    "responses": {
      "RateLimited": {
        "description": "RATE_LIMITED: too many requests in the current 60-second window. `error.details.retry_after_seconds` repeats Retry-After. RateLimit reports r=0 and the seconds until the window resets.",
        "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: unexpected server error. Retry with exponential backoff.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "AuthUnavailable": {
        "description": "SERVICE_UNAVAILABLE: the gateway could not check the credential (temporary backend failure). Not cached: retry with backoff.",
        "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: include_eidas was requested but qualified timestamping is not configured on this deployment; retry without include_eidas. SERVICE_UNAVAILABLE: the gateway could not check the credential (temporary backend failure, not cached); retry with backoff.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "401, two codes. MISSING_API_KEY: the Authorization header is missing or malformed (`WWW-Authenticate: Bearer realm=\"ProofSnap API\"`). INVALID_API_KEY: the credential was sent but is not valid: an unknown, revoked or expired API key, an account without API access, or an invalid or revoked session token (`WWW-Authenticate: Bearer realm=\"ProofSnap API\", error=\"invalid_token\"`). Do not retry with the same credential. 403 means the credential is valid but not allowed to do this (FORBIDDEN, plan gates).",
        "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": "Every error has this shape. Error responses carry the `X-Request-ID` header with the same value as `request_id`.\n\nRequest size: a request body can be at most 6 MB (the Lambda payload limit; API Gateway itself stops at 10 MB). A larger body is refused before it reaches the API code, so that response does not have this shape.\n\n| HTTP | code | Meaning |\n| --- | --- | --- |\n| 400 | `MALFORMED_REQUEST` | Body is not valid JSON (message \"Request body is not valid JSON\"), or the request is invalid: when `details.reason` says which field and why, `message` repeats that reason, otherwise it is \"The request is invalid.\" |\n| 400 | `MISSING_FIELD` | A required field is missing. |\n| 400 | `INVALID_URL` | URL is not a valid http(s) URL, is longer than 2048 characters, or a webhook_url is not https. |\n| 400 | `INVALID_HASH` | certify-hash: a hash is not 64 hex characters. |\n| 400 | `INVALID_TIMESTAMP_TYPE` | options.timestamp_type is not \"bitcoin\". |\n| 400 | `INVALID_VIEWPORT` | options.viewport is not \"desktop\" or \"mobile\". |\n| 400 | `METADATA_TOO_LARGE` | options.metadata has more than 10 keys, a non-string value or a value over 500 characters. |\n| 400 | `INVALID_EVIDENCE_PACK_OPTIONS` | options.evidence_pack is invalid. |\n| 400 | `INVALID_BATCH` | Batch urls is not an array of 2 to 50 valid URLs. |\n| 400 | `REDACTION_NO_OPS` | Redaction has no operations, or none of them matched anything. |\n| 400 | `INVALID_CRON` | cron_expr could not be parsed. |\n| 400 | `INTERVAL_TOO_SHORT` | Schedule cadence is below the plan minimum. |\n| 400 | `RATE_LIMIT_COST_TOO_HIGH` | The request needs more rate-limit units than one window allows; `details.max_per_window`, `details.window_seconds`, `details.requested`. Split it. |\n| 400 | `WEBHOOK_REQUIRES_API_KEY` | A `webhook_url` was sent from a dashboard session. Webhooks are signed with an API key's webhook secret, so they need an API key. |\n| 401 | `MISSING_API_KEY` | Authorization header missing or malformed. `WWW-Authenticate: Bearer realm=\"ProofSnap API\"`. |\n| 401 | `INVALID_API_KEY` | Key unknown, revoked or expired, no API access, or session token invalid or revoked. `WWW-Authenticate: Bearer realm=\"ProofSnap API\", error=\"invalid_token\"`. |\n| 402 | `QUOTA_EXCEEDED` | Monthly maximum or sandbox allowance reached (also in the `error` of a background certification refused for that reason). |\n| 402 | `PAYMENT_REQUIRED` | Live subscription is past due or unpaid (also in the `error` of a scheduled capture skipped for that reason). |\n| 403 | `FORBIDDEN` | The resource belongs to someone else, or the action is owner-only. Message: \"You do not have access to this resource.\" (details.reason says more where it helps). |\n| 403 | `EMAIL_NOT_VERIFIED` | A dashboard session whose email address is not confirmed tried to create something persistent, paid or that invites people (captures, batches, comparisons, redactions, API keys, webhook secrets, checkout, plan changes, seat changes, certify-hash, schedules, company invites, matters and every change to them, matter members, captures assigned to matters, shares and their revocation, seals, exports, legal holds). Reads stay open. Message: \"Confirm your email address first. Use the link we sent you, or send a new one from the dashboard.\" An API key is refused the same way when its account's email address is not confirmed. |\n| 403 | `EIDAS_REQUIRES_LIVE_KEY` | include_eidas sent with a test key (a ps_live_ key of an account without a linked billing account counts as one), or for a comparison or redaction of a test capture (`details.reason`). A live key or session that runs as test because no billing account is linked gets `details.reason` \"no_billing_account\". |\n| 403 | `MATTERS_NOT_ENABLED` | Matters need a Legal plan. |\n| 403 | `SCHEDULE_LIMIT_REACHED` | Active-schedule limit of the plan reached (or the plan has no schedules). |\n| 403 | `ON_CHANGE_REQUIRES_PLAN` | The plan does not allow on_change monitoring. |\n| 403 | `PLAN_ARCHIVE_ONLY` | The Archive only plan keeps your evidence readable, downloadable and verifiable; it creates no captures, schedules, certifications, comparisons or redactions. |\n| 403 | `PLAN_NOT_AVAILABLE` | The plan cannot be chosen by this account: Archive only is bought after a paid API subscription has ended (while the evidence is still kept) or reached by changing plan from developer, startup, growth, website_archive or legal_solo; the company archive only by the owner of a company whose subscription ended, within its download window. `details.reason` says why. |\n| 403 | `PLAN_API_READ_ONLY` | Website Archive plan: the API key is read only (list, download, verify, webhooks). Create captures, schedules and certifications in the dashboard, or use an API plan. |\n| 403 | `LEGAL_HOLD_OWNER_ONLY` | Only the company owner releases the legal hold of a company capture (a seat may place it). |\n| 403 | `LEGAL_HOLD_NOT_AVAILABLE` | Placing a legal hold needs a paid plan (`details.reason` \"plan\": the free sandbox, or an account whose subscription ended) and a live capture (`details.reason` \"test_capture\": a watermarked test-mode capture). Releasing a hold is never refused for this. |\n| 403 | `IMPORT_NOT_ALLOWED` | Package import needs a Legal plan (`details.reason` \"plan\") and a live API key or a dashboard session with linked billing (`details.reason` \"live_credentials_required\"). |\n| 404 | `CAPTURE_NOT_FOUND` | Capture does not exist, was deleted, or the ID is malformed. |\n| 404 | `MATTER_NOT_FOUND` | Matter does not exist or you are not a member. |\n| 404 | `IMPORT_NOT_FOUND` | Import does not exist, belongs to another account, or the ID is malformed. |\n| 404 | `WEBHOOK_SUBSCRIPTION_NOT_FOUND` | Webhook subscription does not exist, was deleted, or belongs to another account. |\n| 404 | `SCHEDULE_NOT_FOUND` | Schedule does not exist or belongs to another account. |\n| 404 | `NOT_FOUND` | Other resource not found (ledger hash format, share token, export job, member). |\n| 404 | `EXPORT_JOB_NOT_FOUND` | A capture export job does not exist, belongs to another account, has expired, or was asked for with a test key. |\n| 405 | `METHOD_NOT_ALLOWED` | The HTTP method is not allowed for this route. |\n| 409 | `CAPTURE_PROCESSING` | The capture is still processing and cannot be deleted (or have its legal hold changed) yet. |\n| 409 | `CAPTURE_LEGAL_HOLD` | The capture is under legal hold and cannot be deleted. |\n| 409 | `CAPTURE_IN_TRASH` | Legal hold: the capture is in the trash; restore it first. |\n| 409 | `CAPTURE_HOLD_GRACE` | Permanent deletion within the grace after the capture's legal hold was released; `details.until`. Trash is allowed. |\n| 409 | `LEGAL_HOLD_MATTER` | The capture's hold was placed by its matter's legal hold, which is still on: release it on the matter (`details.matter_id` for a reader of the matter). |\n| 409 | `LEGAL_HOLD_SOURCE` | The hold of a redaction or comparison follows a capture it was made from (for a comparison, either compared capture), which is still under legal hold (placed on that capture or through its matter): the package stays held until that hold is released (`details.source_capture_id`). |\n| 409 | `COMPANY_READ_ONLY` | The company's subscription has ended (or it is kept as a company archive): its matters are opened, exported and their share links revoked, nothing else, and nothing new is derived from its captures. |\n| 409 | `WEBHOOK_SUBSCRIPTION_LIMIT` | Active webhook subscription limit of the plan reached (25 on API and Legal plans, 10 on Website Archive, 0 on Archive only and sandbox); `details.limit`, `details.active`. |\n| 409 | `WEBHOOK_SECRET_ROTATION_PENDING` | The previous webhook secret is still inside its 24-hour overlap; `details.previous_secret_expires_at`. |\n| 409 | `IDEMPOTENCY_IN_PROGRESS` | The first request with this Idempotency-Key is still being accepted. |\n| 409 | `COMPARE_NOT_READY` | Both captures must be completed before comparing. |\n| 409 | `REDACTION_NOT_READY` | The source capture must be completed before redacting. |\n| 409 | `MATTER_SEALED` | The matter (or the capture's matter) is sealed and cannot change. |\n| 409 | `MATTER_SEALING` | A seal of the matter is running (at most 60 s): no capture assignment, rename, member change, trash of a linked capture or second seal meanwhile. `Retry-After`. |\n| 409 | `MATTER_ARCHIVED` | The matter is archived: no new captures, renames, new members, sharing or export; reopen it (PATCH status \"open\") first. Removing a capture or a member still works. |\n| 409 | `MATTER_CHANGED` | The custody log changed while sealing; nothing was sealed, retry. |\n| 409 | `MATTER_LINKED` | Capture delete: the capture is an exhibit of a matter the caller no longer belongs to. The matter owner removes it from the matter first. |\n| 409 | `MATTER_HAS_TRASHED_CAPTURES` | Matter seal: a capture of the matter is in the trash. Restore it or remove it from the matter, then seal again. |\n| 409 | `MEMBER_HAS_INDIVIDUAL_SUBSCRIPTION` | Company invite: the invitee's own API subscription still renews. They cancel it, then the owner invites them again. |\n| 409 | `IMPORT_LIMIT` | 3 imports are already in progress (uploading or verifying); `details.limit`, `details.in_progress`. |\n| 409 | `IMPORT_STATE` | The import is not in a state that allows this (complete or cancel of an import that is no longer uploading); `details.status`. |\n| 409 | `EXPORT_JOB_RUNNING` | An export of your captures is still being prepared; `details.job_id` and `details.poll_path` name it. Start a new one when it has ended. |\n| 413 | `IMPORT_TOO_LARGE` | The package is larger than 2 GiB; `details.max_bytes`. |\n| 413 | `VERIFY_PACKAGE_TOO_LARGE` | POST /captures/{capture_id}/verify: the stored package is above 256 MiB; verify it offline with the bundled scripts. `details.size_bytes`, `details.limit_bytes`. |\n| 422 | `IDEMPOTENCY_KEY_REUSED` | Idempotency-Key reused with a different body or a different key environment (live or test). |\n| 422 | `SSRF_BLOCKED` | URL targets a private, loopback, link-local or otherwise blocked address. |\n| 422 | `TARGET_UNREACHABLE` | The URL's hostname has no DNS records, or could not be resolved. |\n| 422 | `REDACTION_SCREENSHOT_UNREADABLE` | The screenshot could not be decoded for box redaction. |\n| 422 | `REDACTION_LEAK_DETECTED` | A redacted term survived the redaction outside the capture URL; nothing was issued. `details.files`, `details.rule_indexes`, `details.metadata_fields`, `details.in_capture_url`. |\n| 422 | `REDACTION_SOURCE_UNSUPPORTED` | Only web page captures can be redacted. `details.source` or `details.manifest_type`. |\n| 422 | `REDACTION_SOURCE_INTEGRITY_FAILED` | A file of the source capture does not match the SHA-256 its signed manifest lists, or is missing; nothing was issued. `details.mismatched`, `details.missing`. |\n| 422 | `COMPARE_PROFILE_MISMATCH` | POST /compare: the two captures were made with a different device profile or different capture settings; `details.differing`, `details.before`, `details.after`. Send allow_profile_mismatch: true to compare anyway (the text and image result is then undetermined). |\n| 422 | `COMPARE_URL_MISMATCH` | POST /compare: the two captures are of different pages; `details.before`, `details.after` (url and final_url as origin and path). Send allow_url_mismatch: true to compare anyway. |\n| 422 | `RULE902_NOT_AVAILABLE_FOR_MISMATCH` | POST /compare: include_rule_902 for captures rendered differently (allow_profile_mismatch); `details.differing`. |\n| 422 | `COMPARE_SOURCE_INTEGRITY_FAILED` | A file of a source capture of POST /compare does not match the SHA-256 its signed manifest lists, or is missing; nothing was compared or charged. `details.capture_id`, `details.mismatched`, `details.missing`, `details.sources[]`. |\n| 422 | `REDACTION_PATTERN_TOO_SLOW` | A text_redactions regex pattern ran too long on this page (catastrophic backtracking); nothing was issued or charged. `details.rule_index`. Simplify the pattern. |\n| 422 | `IMPORT_SIZE_MISMATCH` | The uploaded package does not have the announced size_bytes; the upload was discarded. `details.expected`, `details.actual`. |\n| 422 | `IMPORT_NOT_SUPPORTED` | Compare or redact of an imported evidence package. |\n| 429 | `RATE_LIMITED` | Too many requests in the current window, or a daily cap on matter create (200), seal (20), export (50) member invites (50) or package imports (50, `import_create`) was reached (`details.daily_limit`, `details.action`). `Retry-After` says when to retry. |\n| 429 | `CONCURRENT_CAPTURE_LIMIT` | Too many captures of the account are running at once (developer 10, startup 25, growth 100, legal_solo and legal_seats 25, custom 200, website_archive 25, sandbox 5). A batch starts as many URLs as fit and lists the rest in `not_admitted`; with no free slot the whole request is refused. `details.limit`, `details.in_flight`, `details.requested`, `Retry-After`. |\n| 429 | `TEST_MODE_DAILY_LIMIT` | Test-mode work (a test key or a session without linked billing; watermarked, not billed) is limited to 200 units per account and UTC day (a batch counts its URLs). `details.used`, `details.limit`, `details.reset_at`, `Retry-After`. |\n| 429 | `IMPORT_DAILY_BYTES_LIMIT` | Package import: the account's import volume of the UTC day (5 GiB of announced sizes) is reached. `details.period`, `details.limit_bytes`, `details.used_bytes`, `details.requested_bytes`, `details.reset_at`. |\n| 429 | `IMPORT_MONTHLY_BYTES_LIMIT` | Package import: the account's import volume of the UTC month (20 GiB) is reached. Same details. |\n| 500 | `INTERNAL_ERROR` | Unexpected error. Retry with backoff. |\n| 500 | `CAPTURE_FAILED` | The capture could not be started, or (in a failed capture's `error`) could not be completed. |\n| 500 | `CAPTURE_TIME_BUDGET_EXCEEDED` | In a failed capture's `error`, or a synchronous redaction: the run ran out of its time budget and no package was issued or charged. |\n| 500 | `CAPTURE_SCREENSHOT_TIMEOUT` | In a failed capture's `error`: the browser did not finish rendering the screenshot: every way of taking it (one shot, smaller bands, the browser's own screenshot) timed out, and so did the one retry in a newly started browser when the time budget left room for it. Nothing was charged; try again. |\n| 500 | `CERTIFY_FAILED` | In a failed certification's `error`: the certification could not be completed. |\n| 500 | `REDACTION_FAILED` | In a failed redaction's `error`: the redaction could not be completed. A redaction refused for a known reason carries that reason's code instead (for example REDACTION_LEAK_DETECTED). |\n| 500 | `TIMESTAMP_SERVICE_UNAVAILABLE` | Matter seal: the OpenTimestamps anchor failed; nothing changed, retry. |\n| 500 | `CONFIGURATION_ERROR` | A server-side step failed or is not configured (`details.reason`). |\n| 503 | `SERVICE_UNAVAILABLE` | Authentication is temporarily unavailable (the credential could not be checked). Not cached; retry. |\n| 503 | `EIDAS_UNAVAILABLE` | include_eidas requested but eIDAS timestamping is not configured. |\n| 503 | `EIDAS_SEAL_RETRY` | Matter seal: the eIDAS timestamp did not arrive in time; nothing was sealed. Retry after `Retry-After`; the retry reuses the timestamp already issued. |\n| 503 | `LIMIT_CHECK_UNAVAILABLE` | Matter seal: its daily limit could not be checked; nothing was done. Retry. |\n| 504 | `UPSTREAM_TIMEOUT` | A synchronous processing step (certify-hash, matter export) did not respond in time. It may still complete: poll what `details` names (`certify_id`, `poll_path`), or retry. |\n| 404 | `ORG_INVITE_NOT_FOUND` | A company invite that is unknown, addressed to someone else, no longer pending, or whose company no longer runs (details.reason `company_inactive`). |\n| 409 | `ORG_INVITE_NO_SEATS` | Accepting a company invite: every seat of the company is in use; the invite stays open. |\n| 409 | `ORG_INVITE_IN_OTHER_ORG` | Accepting a company invite: the caller holds another company's seat; the invite stays open. |\n| 409 | `ORG_INVITE_HAS_SUBSCRIPTION` | Accepting a company invite: the caller's own API subscription still renews; the invite stays open. |\n| 409 | `ORG_OWNER_CANNOT_LEAVE` | The company owner tried to leave their own company. |\n| 410 | `ORG_INVITE_EXPIRED` | Accepting a company invite older than 7 days. |",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string",
                "description": "Machine-readable error code (UPPER_SNAKE_CASE)."
              },
              "message": {
                "type": "string",
                "description": "Human-readable message."
              },
              "details": {
                "type": "object",
                "additionalProperties": true,
                "description": "Optional extra context, for example `reason`, `field` or `retry_after_seconds`."
              }
            },
            "required": [
              "code",
              "message"
            ]
          },
          "request_id": {
            "type": "string",
            "description": "Request ID."
          }
        },
        "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": "Capture options. Every field is optional.",
        "properties": {
          "scroll_full_page": {
            "type": "boolean",
            "description": "Capture the whole scrollable page. false captures the viewport only.",
            "default": true
          },
          "expand_collapsed": {
            "type": "boolean",
            "description": "Open collapsed sections before the screenshot: closed <details> elements, accordion and disclosure buttons (aria-expanded with aria-controls) and \"see more\" / \"show replies\" text controls. Guarded: nothing inside navigation, headers, menus or dialogs, no links to another URL, no form submission, no state-changing labels (like, follow, buy, delete, ...), each control at most once, and a control that tries to leave the page stops the expansion and the original URL is loaded again. The outcome is recorded per control in forensic_log.json (page_modifications.expand_collapsed: found, opened, left closed, skipped reasons, requests started, page height before and after) with a summary in metadata.json (expand_collapsed: requested, opened_total, left_closed_total); evidence.pdf and the certificates state only what was actually opened.",
            "default": true
          },
          "viewport": {
            "type": "string",
            "description": "desktop is 1920x1080 at pixel ratio 1; mobile is 390x844 at pixel ratio 3 with a mobile Safari user agent. Other values return 400 INVALID_VIEWPORT.",
            "enum": [
              "desktop",
              "mobile"
            ],
            "default": "desktop"
          },
          "viewport_width": {
            "type": "number",
            "minimum": 320,
            "maximum": 3840,
            "description": "Custom viewport width in CSS pixels. Must be sent together with viewport_height, otherwise 400 MALFORMED_REQUEST."
          },
          "viewport_height": {
            "type": "number",
            "minimum": 240,
            "maximum": 4320,
            "description": "Custom viewport height in CSS pixels. Must be sent together with viewport_width."
          },
          "device_scale_factor": {
            "type": "number",
            "minimum": 1,
            "maximum": 3,
            "description": "Device pixel ratio of the screenshot."
          },
          "wait_for_selector": {
            "type": "string",
            "description": "Wait up to 10 seconds for this CSS selector before capturing. If it does not appear, the capture continues without error. Recorded in forensic_log.json page_modifications.",
            "maxLength": 200
          },
          "wait_ms": {
            "type": "number",
            "minimum": 0,
            "maximum": 15000,
            "description": "Extra wait in milliseconds after the page has loaded."
          },
          "screenshot_format": {
            "type": "string",
            "description": "jpeg ships screenshot.jpeg; png ships a lossless screenshot.png. C2PA Content Credentials are only added to JPEG screenshots.",
            "enum": [
              "jpeg",
              "png"
            ],
            "default": "jpeg"
          },
          "url_stamp": {
            "type": "boolean",
            "description": "Add a header band above the page image showing the captured URL, the UTC capture time (the moment the browser produced the screenshot; for a long page shot in bands, the moment the last band was produced) and the evidence ID, labelled as a software annotation. The band is added on top of the image, so no page pixel is covered. chain_of_custody.json records screenshot_url_header.raw_screenshot_sha256 with raw_screenshot_source: for a page shot in one piece (browser_image, browser_png, browser_jpeg_joined, browser_jpeg_reencoded) the SHA-256 of the page image the browser produced, before the band; for a long page shot in bands and joined (joined_bands, joined_bands_lossless, joined_bands_reencoded) the SHA-256 of the joined image as ProofSnap wrote it with the band, before any C2PA Content Credentials. When the band cannot be added (with it the image would exceed the 65,535 px a JPEG can hold, drawing it failed, or, on the fallback screenshot path only, the page image is over 32,000,000 pixels) the screenshot is delivered without it: metadata.json records this in screenshot.url_stamp_skipped and chain_of_custody.json in screenshot_url_header (applied false, with the reason).",
            "default": false
          },
          "network_log": {
            "type": "boolean",
            "description": "Include network.har (HAR with request and response headers and timing, bodies omitted) and network.original.har.",
            "default": true
          },
          "warc": {
            "type": "boolean",
            "description": "Include capture.warc (ISO 28500) with the HTTP transactions of the page load. The response bodies are read within a time limit (at most 30 s, and only while the optional part of the capture's time budget allows, after the time kept for eIDAS, C2PA and the closing clock reading); a body cut by that limit is stored empty with WARC-Truncated: time, a response not reached is left out, and warc_captured in chain_of_custody.json (and warc in forensic_log.json) records truncated, truncation_reason, response_pairs, records_total and the counts.",
            "default": false
          },
          "external_archive": {
            "type": "boolean",
            "description": "Ask the Internet Archive Save Page Now service to archive the URL (the Internet Archive keeps that copy publicly). The result is classified by whose request produced the snapshot and its time relative to the request: created (made for this request), recent_existing (the Internet Archive answered with an earlier snapshot), created_after_request (a newer snapshot exists but the request was not confirmed), existing (only an older snapshot exists), pending, rate_limited, failed (the Internet Archive refused the request), unconfirmed (no answer confirmed or refused it and no snapshot is known), unavailable, skipped, skipped_sensitive or skipped_query. URLs that look credentialed or carry token-like parameters, parameter names or path segments are never sent. A URL with a query string is sent only when this option is set to true explicitly; with the default it is skipped (skipped_query). The result is recorded in forensic_log.json (external_archive, with snapshot_offset_seconds against the capture) and chain_of_custody.json, and returned as external_archive by GET /captures/{capture_id}. Set false for private URLs. The request is sent as soon as the page has loaded, in parallel with the rest of the capture. A failure does not fail the capture.",
            "default": true
          },
          "dismiss_consent": {
            "type": "boolean",
            "description": "Pre-set accept-all consent cookies before loading the page and try to dismiss cookie consent banners. This changes the page, so it is off by default; what was done is recorded in forensic_log.json consent_dismissal.",
            "default": false
          },
          "dismiss_popups": {
            "type": "boolean",
            "description": "Close newsletter, promotion and similar overlay popups through their own close control. The number of popups closed and the controls used are recorded in forensic_log.json page_modifications. Each closed pop-up is recorded in forensic_log.json page_modifications (popups_dismissed, popups_dismissed_controls); evidence.pdf states how many were closed.",
            "default": false
          },
          "dark_mode": {
            "type": "boolean",
            "description": "Render with the dark color scheme. Changes the page appearance; recorded in forensic_log.json page_modifications. Disclosed in evidence.pdf and in forensic_log.json page_modifications.",
            "default": false
          },
          "block_ads": {
            "type": "boolean",
            "description": "Block requests to known ad and tracker hosts. Omits content; recorded in forensic_log.json page_modifications.",
            "default": false
          },
          "block_resources": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "image",
                "font",
                "media",
                "stylesheet"
              ]
            },
            "description": "Resource types to block. Omits content; recorded in forensic_log.json page_modifications."
          },
          "inject_css": {
            "type": "string",
            "description": "CSS added to the page before capture. Changes the page; stored verbatim in forensic_log.json page_modifications. Always stored verbatim in forensic_log.json page_modifications (injected_css) with injected_css_status `applied` or `failed` and injected_css_error. evidence.pdf states that custom CSS was injected; it does not print the code.",
            "maxLength": 10000
          },
          "inject_js": {
            "type": "string",
            "description": "JavaScript run in the page before capture. Changes the page. Always stored verbatim in forensic_log.json page_modifications (injected_js) with injected_js_status and injected_js_error: `applied` (it ran to the end), `invalid` (it did not compile, for example a syntax error, so it never ran and did not change the page), `failed` (it threw while running, so it may have changed the page before it stopped) or `timed_out` (the capture stopped waiting for it after 5 seconds; the script may have kept running and changing the page). evidence.pdf states the outcome and points to forensic_log.json, and the provenance certificate lists injected code among the page modifications unless it was `invalid`; neither prints the code.",
            "maxLength": 10000
          },
          "locale": {
            "type": "string",
            "description": "Browser locale to emulate, for example de or de-DE (sets the browser language and Accept-Language). Emulation only: no proxy is used, so content that depends on the visitor's IP still reflects the capture server's region. Recorded in metadata.json geo.",
            "pattern": "^[a-z]{2}(-[A-Z]{2})?$"
          },
          "timezone": {
            "type": "string",
            "description": "Browser timezone to emulate (IANA name, for example Europe/Berlin). Emulation only."
          },
          "language": {
            "type": "string",
            "description": "Language of the human-readable PDFs. One of: 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. Case, hyphen and region are tolerated (\"DE\", \"fr-FR\" and \"PT-BR\" are accepted); bare \"pt\" and \"zh\" are rejected. An unsupported value returns 400 MALFORMED_REQUEST.",
            "default": "en",
            "example": "de"
          },
          "timestamp_type": {
            "type": "string",
            "description": "Only \"bitcoin\" is accepted (the OpenTimestamps anchor is always attempted). Any other value returns 400 INVALID_TIMESTAMP_TYPE. Use include_eidas for a qualified timestamp.",
            "enum": [
              "bitcoin"
            ],
            "default": "bitcoin"
          },
          "include_eidas": {
            "type": "boolean",
            "description": "Also request an eIDAS qualified timestamp (RFC 3161) over the SHA-256 of manifest.json, shipped as manifest.json.tsr (plus eidas_validation.json when validation data is returned). Live keys only: a test key returns 403 EIDAS_REQUIRES_LIVE_KEY. If the deployment has no eIDAS service configured, the request is rejected with 503 EIDAS_UNAVAILABLE before anything is billed. Before a token is used, ProofSnap checks it (signature, the hash it carries, certificate chain to the CA Disig QCA4 root, the TSA certificate status) and records that check in eidas_validation.json (verification). If the timestamp request fails during the capture, or the token does not pass that check, the package ships without it with the OpenTimestamps anchor only, the stamp is not billed and timestamps.eidas (capture row, GET /captures/{capture_id}, completed webhook) says status not_obtained with the reason (token_refused, unavailable or time_budget). From the screenshot on, the optional steps of the capture (the WARC bodies, the archive and WHOIS answers, C2PA signing, the second clock reading) leave time for the stamp and are cut first. Billed per stamp.",
            "default": false
          },
          "include_rule_902": {
            "type": "boolean",
            "description": "Add rule_902_certification.pdf: an unsigned [FRE 902(13)/(14)](https://www.govinfo.gov/content/pkg/USCODE-2023-title28/html/USCODE-2023-title28-app-federalru-dup2-rule902.htm) and [28 U.S.C. 1746](https://www.govinfo.gov/content/pkg/USCODE-2023-title28/html/USCODE-2023-title28-partV-chap115-sec1746.htm) certification template for a person to complete and sign. Its SHA-256 is listed in manifest.json. It is also added when evidence_pack.custodian has a non-empty value. If generating it fails, the capture completes without it. Currently disabled: the certification template is temporarily switched off, so this field is accepted and ignored (no rule_902_certification.pdf is generated or listed in the package) and the response carries the warning RULE902_DISABLED.",
            "default": false
          },
          "case_reference": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PlainText"
              }
            ],
            "description": "Your matter or case label. Whitespace is collapsed. Follows the plain-text rule (see PlainText). Recorded in metadata.json, forensic_log.json and chain_of_custody.json, and printed on evidence.pdf.",
            "maxLength": 200
          },
          "case_id": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PlainText"
              }
            ],
            "description": "Your system's case identifier. Recorded like case_reference; follows the plain-text rule (see PlainText).",
            "maxLength": 200
          },
          "case_note": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PlainText"
              }
            ],
            "description": "Free-text note. Follows the plain-text rule (see PlainText). Recorded in metadata.json, forensic_log.json and chain_of_custody.json (legal_context.note).",
            "maxLength": 500
          },
          "webhook_url": {
            "type": "string",
            "description": "https URL that receives capture.completed or capture.failed. See Webhooks. The URL must be https and must not point to a private, loopback, link-local, carrier-grade NAT or cloud-metadata address (the same rules as a capture URL; 422 SSRF_BLOCKED otherwise). An empty or whitespace-only value means no webhook. Needs an API key: sent from a dashboard session it is refused with 400 WEBHOOK_REQUIRES_API_KEY, because webhooks are signed with the key's webhook secret.",
            "format": "uri"
          },
          "metadata": {
            "type": "object",
            "additionalProperties": {
              "type": "string",
              "maxLength": 500
            },
            "maxProperties": 10,
            "description": "Up to 10 string tags (values up to 500 characters), returned with the capture. Violations return 400 METADATA_TOO_LARGE."
          },
          "evidence_pack": {
            "type": "object",
            "properties": {
              "enabled": {
                "type": "boolean",
                "description": "false leaves the certification page out of evidence.pdf.",
                "default": true
              },
              "jurisdiction": {
                "type": "string",
                "description": "Recorded in forensic_log.json options. It has no other effect.",
                "enum": [
                  "US-Federal",
                  "EU-eIDAS"
                ],
                "default": "US-Federal"
              },
              "custodian": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Accepted; not printed in the template, whose declarant fields stay blank for the person who signs it.",
                    "maxLength": 200
                  },
                  "law_firm": {
                    "type": "string",
                    "description": "Accepted; not printed in the template, whose declarant fields stay blank for the person who signs it.",
                    "maxLength": 200
                  },
                  "role": {
                    "type": "string",
                    "description": "Accepted; not used in the template.",
                    "maxLength": 200
                  },
                  "case_caption": {
                    "type": "string",
                    "description": "Accepted; not used in the template.",
                    "maxLength": 200
                  }
                },
                "additionalProperties": false,
                "description": "Every value follows the plain-text rule (see PlainText); 400 INVALID_EVIDENCE_PACK_OPTIONS otherwise."
              }
            },
            "description": "Invalid values return 400 INVALID_EVIDENCE_PACK_OPTIONS."
          }
        }
      },
      "CaptureRequest": {
        "type": "object",
        "required": [
          "url"
        ],
        "properties": {
          "url": {
            "type": "string",
            "description": "Page to capture. http or https only, at most 2048 characters. Private, loopback, link-local and metadata addresses are refused with 422 SSRF_BLOCKED, and a hostname without DNS records returns 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": "Status path including the /v1 stage, for example /v1/captures/cap_..."
          },
          "message": {
            "type": "string"
          },
          "metadata": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          },
          "private_note": {
            "type": "string",
            "description": "The private note as stored (normalised), present only when one was sent."
          },
          "warnings": {
            "$ref": "#/components/schemas/Rule902DisabledWarnings"
          }
        }
      },
      "CaptureResult": {
        "type": "object",
        "description": "A capture, certificate, comparison or redaction. Fields depend on status.",
        "properties": {
          "capture_id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "processing",
              "completed",
              "failed"
            ]
          },
          "url": {
            "type": "string",
            "description": "Captured URL (null for certificates and comparisons).",
            "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 on comparisons, import on an imported evidence package.",
            "enum": [
              "comparison",
              "import"
            ]
          },
          "compared": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Only on comparisons: the two source capture IDs."
          },
          "diff_summary": {
            "$ref": "#/components/schemas/DiffSummary"
          },
          "estimated_completion_seconds": {
            "type": "integer",
            "description": "processing only: rough estimate, at least 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": "The public reason of the failure (see the Error table). CAPTURE_SCREENSHOT_TIMEOUT: the browser did not finish the screenshot; CAPTURE_TIME_BUDGET_EXCEEDED: the run used up its whole time budget; neither is charged. A comparison row (cmp_) can also fail with COMPARE_SOURCE_INTEGRITY_FAILED (a source package altered after the request was accepted), COMPARE_PROFILE_MISMATCH or COMPARE_URL_MISMATCH (found only in the sealed records the run compares) or RULE902_NOT_AVAILABLE_FOR_MISMATCH; the unit is given back. A hash certification run in the background fails with QUOTA_EXCEEDED when the monthly maximum is reached by the time it runs. CERTIFY_FAILED and REDACTION_FAILED: a hash certification or a redaction could not be completed; retry the request."
              },
              "message": {
                "type": "string"
              }
            },
            "description": "failed only."
          },
          "failed_at": {
            "type": "string",
            "description": "failed only.",
            "format": "date-time"
          },
          "captured_at": {
            "type": "string",
            "description": "When the page was captured (completed rows only). For a redaction, the capture time of its source capture (redacted_at is when the redaction was made).",
            "format": "date-time"
          },
          "redacted_at": {
            "type": "string",
            "format": "date-time",
            "description": "Redactions only: when the redaction was made. The captured_at of a redaction is the capture time of its source capture."
          },
          "compared_at": {
            "type": "string",
            "nullable": true,
            "format": "date-time",
            "description": "Comparisons only: when the comparison was made. Its captured_at carries the same value (kept for older readers); use compared_at."
          },
          "package_url": {
            "type": "string",
            "description": "completed only: presigned download link of the package ZIP, valid for up to 24 hours (see package_url_expires_at); read the capture again for a fresh link.",
            "format": "uri"
          },
          "package_url_expires_at": {
            "type": "string",
            "description": "completed only: when package_url stops working, at most 24 hours after this response (a link signed with temporary server credentials can stop earlier); read the capture again for a fresh link.",
            "format": "date-time"
          },
          "verification_url": {
            "type": "string",
            "description": "completed only: link to the public ProofSnap verifier.",
            "format": "uri"
          },
          "manifest_hash": {
            "type": "string",
            "description": "completed only: sha256:<hex> of 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> of the signed screenshot.",
                "nullable": true
              }
            },
            "description": "completed only."
          },
          "file_count": {
            "type": "integer",
            "description": "completed only."
          },
          "size_bytes": {
            "type": "integer",
            "description": "completed only."
          },
          "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": "completed only, when the capture landed on a cookie consent wall or interstitial instead of the page. Re-run with dismiss_consent: true."
          },
          "external_archive": {
            "allOf": [
              {
                "$ref": "#/components/schemas/ExternalArchive"
              }
            ],
            "description": "completed only. The Internet Archive request made with the external_archive option: the current result (status), the state sealed in the package (status_at_sealing) and the snapshot link. { requested: false } when no request was made."
          },
          "storage": {
            "$ref": "#/components/schemas/CaptureStorage"
          },
          "private_note": {
            "type": "string",
            "nullable": true,
            "description": "Your private note (see PrivateNote). Returned to the capture's maker only; never part of the evidence package."
          },
          "legal_hold": {
            "type": "boolean",
            "description": "The capture is under legal hold: it is not deleted while true (PATCH /captures/{capture_id} with action legal_hold). Released automatically, and the capture deleted, when the subscription ended and its 30 days passed without a plan that keeps data."
          },
          "legal_hold_set_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "When the current legal hold was placed; null without a hold."
          },
          "legal_hold_reason": {
            "type": "string",
            "nullable": true,
            "description": "The reason stored with the current legal hold; null without a hold or reason."
          },
          "legal_hold_set_by_email": {
            "type": "string",
            "nullable": true,
            "description": "Company owner only, on a held company capture: the email address of who placed the hold."
          },
          "legal_hold_source": {
            "type": "string",
            "nullable": true,
            "description": "Where the current hold comes from: \"manual\" (PATCH /captures/{capture_id}), \"bulk\" (POST /legal-holds), \"matter:<matter_id>\" (the matter's legal hold) or \"derived:<capture_id>\" (a redaction or comparison held because a capture it was made from is held). null without a hold."
          },
          "legal_hold_released_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "When the last hold was released (start of the release grace); null while held or never released, and after the automatic release at the end of a subscription (no grace)."
          },
          "deletion_blocked_until": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "End of the grace after a release: until then the capture is not permanently deleted (permanent DELETE 409 CAPTURE_HOLD_GRACE, no trash purge, no retention deletion); moving it to the trash is possible. The account and company deletion after the subscription ended does not wait for it. null when there is no running grace (also while a hold is on: that blocks without an end)."
          },
          "s3_legal_hold": {
            "type": "object",
            "nullable": true,
            "description": "The S3 Object Lock legal hold of the stored package objects (ZIP, upgraded OpenTimestamps proof and the separately stored duplicate, every version). status: on, off, pending (being applied), failed (retried by the server), unsupported (the storage has no Object Lock configuration; the hold on the capture record still applies), none (no package is stored, e.g. a failed capture).",
            "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": "Redactions and comparisons: true when a capture they were made from is under legal hold. A derived package follows its source's hold: while a source is held, the redaction or comparison is held too (legal_hold_source derived:<capture_id>) and its hold cannot be released on its own (409 LEGAL_HOLD_SOURCE). null for other captures."
          },
          "import": {
            "$ref": "#/components/schemas/CaptureImport"
          },
          "source": {
            "type": "string",
            "description": "How the capture was made: api, schedule, certify-hash, compare, redact, import. GET /captures/{capture_id} returns it only for an imported package (\"import\"); the rows of GET /captures always carry it."
          }
        }
      },
      "Timestamps": {
        "type": "object",
        "properties": {
          "bitcoin_ots": {
            "type": "object",
            "properties": {
              "status": {
                "type": "string",
                "description": "State of the Bitcoin OpenTimestamps proof in manifest.json.ots. pending_confirmation: the proof is waiting for a Bitcoin block (usually a few hours; checks start about 2 hours after the capture completed and repeat with growing intervals). verified: a Bitcoin block attests the manifest hash (block_height, block_time). not_present: the package has no manifest.json.ots (the calendars could not be reached when the package was sealed). unconfirmed: no confirmation was found within 7 days of checking; the proof in the package can still be upgraded and checked with the OpenTimestamps client (ots upgrade, ots verify). package_missing: the evidence package is no longer in storage, so the proof cannot be checked any more. manifest_mismatch: manifest.json in the stored package does not match the manifest hash recorded when it was sealed; the timestamp is not confirmed for it. Both are final. The package itself is never changed. not_checked: the capture completed before automatic confirmation checks existed and was never checked; its package may still carry a manifest.json.ots that the OpenTimestamps client can upgrade and verify. Webhook and create responses are sent at completion, so they say pending_confirmation (or not_present when the calendars failed); read GET /captures/{capture_id} for the current state. A package is sealed with the proof pending: at that point the manifest hash has been submitted to the OpenTimestamps calendars. Once confirmed in Bitcoin block {block_height} ({block_time} UTC), the proof shows that manifest.json existed no later than that time; download the upgraded proof (upgraded_proof_url) and place it next to manifest.json as manifest.json.upgraded.ots, and verify.sh and verify.ps1 check it automatically.",
                "enum": [
                  "pending_confirmation",
                  "verified",
                  "not_present",
                  "unconfirmed",
                  "package_missing",
                  "manifest_mismatch",
                  "not_checked"
                ]
              },
              "block_height": {
                "type": "integer",
                "nullable": true,
                "description": "Bitcoin block height that attests the proof. Set when status is verified."
              },
              "block_time": {
                "type": "string",
                "format": "date-time",
                "nullable": true,
                "description": "Time of that Bitcoin block. GET /captures/{capture_id} only, set when status is verified."
              },
              "verified_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true,
                "description": "When ProofSnap confirmed the anchor. GET /captures/{capture_id} only, set when status is verified."
              },
              "upgraded_proof_url": {
                "type": "string",
                "format": "uri",
                "nullable": true,
                "description": "GET /captures/{capture_id} only. Once the anchor is verified, ProofSnap stores the OpenTimestamps proof upgraded from the calendars (it carries the Bitcoin block attestation, so it verifies offline without contacting any calendar). This is a presigned download of it, valid for up to 24 hours like package_url; read the capture again for a fresh link. null when there is none (not verified yet, or stored later by a background backfill). The sealed package is never changed: save the file next to the package's manifest.json as manifest.json.upgraded.ots. Packages sealed from 2026-09-29 verify it with their bundled scripts (verification/verify.sh and verify.ps1 use it instead of manifest.json.ots); for an older package run `ots verify manifest.json.upgraded.ots -f manifest.json`. Matter exports carry it as captures/<capture_id>.upgraded.ots next to captures/<capture_id>.zip. Deleted together with the package."
              }
            }
          },
          "eidas": {
            "$ref": "#/components/schemas/EidasTimestamp"
          }
        }
      },
      "DiffSummary": {
        "type": "object",
        "properties": {
          "text_insertions": {
            "type": "integer",
            "description": "Lines added (the same value as text_lines_added; since 2026-10-14 the text is counted in lines, see text_unit). Comparisons made before count characters. Deprecated (2026-10-14): kept for existing readers, not removed; use text_lines_added.",
            "deprecated": true
          },
          "text_deletions": {
            "type": "integer",
            "description": "Lines removed (the same value as text_lines_removed; lines since 2026-10-14, characters before). Deprecated (2026-10-14): kept for existing readers, not removed; use text_lines_removed.",
            "deprecated": true
          },
          "text_unit": {
            "type": "string",
            "enum": [
              "line"
            ],
            "description": "The unit of the text counts: line (each line of domtextcontent.txt with its line feed). Absent on comparisons made before 2026-10-14, which counted characters."
          },
          "text_lines_added": {
            "type": "integer",
            "description": "Lines added (lines of the After text in added runs of the line diff)."
          },
          "text_lines_removed": {
            "type": "integer",
            "description": "Lines removed (lines of the Before text in removed runs of the line diff)."
          },
          "text_lines_before": {
            "type": "integer",
            "description": "Lines of the Before text."
          },
          "text_lines_after": {
            "type": "integer",
            "description": "Lines of the After text."
          },
          "text_changed_percent": {
            "type": "number",
            "description": "100 x (lines removed + lines added) / (lines before + lines after), one decimal."
          },
          "text_diff_capped": {
            "type": "boolean",
            "description": "true when the line diff reached its work budget and a part of the texts was counted as replaced as a whole (over-reports, never hides a change)."
          },
          "metadata_changes": {
            "type": "array",
            "items": {
              "type": "object"
            },
            "description": "{ key, before, after, class } per metadata.json field that differs, exactly these: url, final_url, page_title, http_status, http_headers.content-type, http_headers.server, dns.connected_ip, resolved_ips (compared as a sorted list), tls.issuer, tls.valid_to, and the device profile fields viewport_width, viewport_height, device_scale_factor, is_mobile and user_agent. class is content (url, final_url, page_title, http_status, http_headers.content-type: counted as a change), infrastructure (http_headers.server, dns.connected_ip, resolved_ips, tls.issuer, tls.valid_to: which server delivered the page, reported only) or device_profile (how the capture rendered the page, reported only). A comparison made before 2026-10-10 has no class."
          },
          "meta_content": {
            "type": "integer",
            "description": "Differing metadata fields of class content; they count toward changed."
          },
          "meta_infra": {
            "type": "integer",
            "description": "Differing metadata fields of class infrastructure (server software, connected IP, resolved IPs, TLS issuer and expiry). A content delivery network answers from rotating addresses, so two loads of an unchanged page can differ here; reported for information, never counted toward changed."
          },
          "meta_device_profile": {
            "type": "integer",
            "description": "Differing device profile fields (viewport, scale, mobile emulation, user agent); reported for information, never counted toward changed."
          },
          "image_changes": {
            "type": "object",
            "description": "The image comparison, from the image inventory each source capture sealed (image_inventory.json). status ran, or not_run with reason source_without_inventory (a source captured before captures recorded one), source_inventory_failed or source_inventory_unreadable; nothing else is used in its place. The full per-image results are in image_changes.json of the package.",
            "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": "Why the images were not compared: source_without_inventory (a source made before captures recorded an image inventory; new captures record one), source_inventory_failed (the inventory could not be recorded when that capture was made) or source_inventory_unreadable; null when the comparison ran."
              },
              "images_changed": {
                "type": "integer",
                "nullable": true,
                "description": "First-party images (served from the page's own registrable domain, or since 2026-10-14 from a domain the page declares as its own og:image or icon, or that serves most of the page's images in both captures: image_changes.json site_image_hosts) replaced, added or removed, the image part of changed; moved images are not included. null when the comparison did not run. Deprecated (2026-10-14): kept for existing readers, not removed; use 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 when a source inventory reached its entry limit; see inventory_cutoff."
              },
              "site_images_changed": {
                "type": "integer",
                "description": "The same as images_changed (counted)."
              },
              "other_images_changed": {
                "type": "integer",
                "description": "Images from other domains and tracking pixels replaced, removed or added (never counted)."
              },
              "site_image_hosts": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "Registrable domains counted as the site's own besides the page's (site_image_hosts of image_changes.json)."
              },
              "inventory_cutoff": {
                "type": "object",
                "nullable": true,
                "properties": {
                  "before_only_index_limit": {
                    "type": "integer",
                    "nullable": true,
                    "description": "An image found only in Before is decidable (removed) when its index in the Before inventory is below this; null: no limit (the After inventory is complete)."
                  },
                  "after_only_index_limit": {
                    "type": "integer",
                    "nullable": true,
                    "description": "An image found only in After is decidable (added) when its index in the After inventory is below this; null: no limit (the Before inventory is complete)."
                  }
                },
                "description": "Since the second audit of 2026-10-14 (image_changes.json algorithm 3) an object, decided by document index: when the OTHER inventory reached its entry limit, an image found on one side only is decidable only when its index is below the other inventory's entry count minus a margin (the larger of 10 entries and 5 percent of that count); otherwise it is undetermined (undetermined_reason beyond_inventory_limit). null when no inventory is truncated. Comparisons made before carry a number (the relative document position of the former rule)."
              }
            }
          },
          "changed": {
            "type": "boolean",
            "nullable": true,
            "description": "true when the visible text differs, a content metadata field differs (meta_content), or a first-party image was replaced, removed or added (image_changes.images_changed). Infrastructure and device profile fields, moved images (the same bytes at another address), third-party images and tracking pixels are reported but do not set it. null (since 2026-10-14) when nothing counted changed reliably but the text or image result is undetermined (captures rendered differently, allow_profile_mismatch). The package states the rule in comparison_method.json counted_as_change. Deprecated (2026-10-14): kept for existing readers, not removed; use verdict (changed | unchanged | undetermined) and the per-kind text_verdict, images_verdict, metadata_verdict.",
            "deprecated": true
          },
          "text_verdict": {
            "type": "string",
            "enum": [
              "changed",
              "unchanged",
              "undetermined"
            ],
            "description": "undetermined when the two captures were rendered with a different device profile or capture settings (allow_profile_mismatch)."
          },
          "images_verdict": {
            "type": "string",
            "enum": [
              "changed",
              "unchanged",
              "undetermined",
              "not_run"
            ]
          },
          "metadata_verdict": {
            "type": "string",
            "enum": [
              "changed",
              "unchanged"
            ],
            "description": "From the content class metadata fields (address, final address, title, HTTP status, content type)."
          },
          "verdict": {
            "type": "string",
            "enum": [
              "changed",
              "unchanged",
              "undetermined"
            ],
            "description": "The overall result in words: changed when changed is true, undetermined when changed is null."
          },
          "undetermined_reason": {
            "type": "string",
            "nullable": true,
            "enum": [
              "render_profile_mismatch",
              "capture_settings_mismatch",
              null
            ]
          },
          "text_changes": {
            "type": "array",
            "items": {
              "type": "object"
            },
            "description": "GET only: up to 60 changed text chunks."
          },
          "change_regions": {
            "type": "array",
            "items": {
              "type": "object"
            },
            "description": "GET only: up to 60 text changes located on the screenshots (from the sources' text_layout.json), in screenshot pixels. Each change carries counterpart_y_px (its vertical place on the other screenshot, carried over from the nearest unchanged text block) and report_region (the number of the change place evidence.pdf shows it in, at most 10 places, or null) as in change_regions.json."
          },
          "change_regions_info": {
            "type": "object",
            "nullable": true
          },
          "order_reversed": {
            "type": "boolean",
            "description": "GET only: true when the capture given as capture_id_1 (Before) was captured after the one given as capture_id_2 (After), as compare.json order_reversed. The comparison keeps the order given; the report and the dashboard say so."
          },
          "render_profile_mismatch": {
            "type": "boolean",
            "description": "GET only: the two captures were rendered with a different device profile (compare.json)."
          },
          "capture_settings_mismatch": {
            "type": "boolean",
            "description": "GET only: the two captures were made with different capture settings (compare.json)."
          },
          "capture_settings_differing": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "setting": {
                  "type": "string"
                },
                "before": {},
                "after": {}
              }
            },
            "description": "GET only: the settings that differ, { setting, before, after }."
          },
          "profile_mismatch_allowed": {
            "type": "boolean",
            "description": "GET only: the comparison was made with allow_profile_mismatch for captures rendered differently. Deprecated (2026-10-14): kept for existing readers, not removed; use render_profile_mismatch and capture_settings_mismatch.",
            "deprecated": true
          },
          "url_mismatch": {
            "type": "boolean",
            "description": "GET only: the two captures share no address (made with allow_url_mismatch)."
          }
        },
        "description": "Comparison summary."
      },
      "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": "Undetermined pairs whose two sides returned the same HTTP error status (for example 404 in both captures); included in undetermined, counted in the report and not listed."
          },
          "occurrences": {
            "type": "integer",
            "description": "All results of this party: occurrences on the page."
          },
          "distinct": {
            "type": "object",
            "description": "The number of different images among the results, in total and per category (an image shown several times counts once): each result stands for the SHA-256 of its After side, or of its Before side when it has none, or for that side's address when it carries no 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": "Counts per category, as occurrences on the page (one image shown three times counts three times): unchanged, replaced (same address, different bytes), moved (different address, same bytes; reported, not counted as a change), removed, added, undetermined (an image without a SHA-256 on either side, never counted as removed or added); plus the paired images whose alt text or place among the page's images changed, the undetermined pairs with the same HTTP error on both sides, and the distinct images."
      },
      "BatchRequest": {
        "type": "object",
        "required": [
          "urls"
        ],
        "properties": {
          "urls": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uri",
              "maxLength": 2048
            },
            "minItems": 2,
            "maxItems": 50,
            "description": "2 to 50 URLs, each validated like CaptureRequest.url. Hostnames are not DNS-checked when the batch is accepted; a URL that fails the DNS or address check fails later with status failed (and capture.failed)."
          },
          "options": {
            "$ref": "#/components/schemas/CaptureOptions"
          },
          "private_note": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PrivateNote"
              }
            ],
            "description": "One private note stored on every capture of the batch. Your private note on the capture (for example \"mobile capture\"), at most 200 characters, plain text (the PlainText rule). It is stored on the capture record only: it is NOT part of the sealed evidence package (not in the ZIP, the PDF, the manifest or any hash) and carries no evidentiary weight, so it can be changed or cleared later with PATCH /v1/captures/{capture_id} without affecting the evidence. Only the capture's maker reads it. Omit it, or send null or an empty string, for no note."
          }
        }
      },
      "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 when the capture could not be started.",
                  "enum": [
                    "processing",
                    "failed"
                  ]
                }
              }
            }
          },
          "not_admitted": {
            "type": "array",
            "description": "Only when the account's in-flight limit was reached: the URLs that were not started, in the order sent. They cost nothing (no rate-limit or monthly unit); send them again later. Not present when every URL was started.",
            "items": {
              "type": "object",
              "properties": {
                "url": {
                  "type": "string"
                },
                "code": {
                  "type": "string",
                  "description": "Why the URL was not started.",
                  "enum": [
                    "CONCURRENT_CAPTURE_LIMIT"
                  ]
                }
              }
            }
          },
          "in_flight_limit": {
            "type": "integer",
            "description": "Only together with not_admitted: the number of captures the account may have running at the same time (developer 10, startup 25, growth 100, legal_solo and 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: signature valid and every listed file present with its recorded SHA-256. modified: signature invalid or a file missing or changed. invalid: the package could not be checked. null only for an imported package that was not checked again this time (`rechecked: false`, with `reason` and the verification recorded at the import in `stored_verification`).",
            "enum": [
              "match",
              "modified",
              "invalid",
              null
            ]
          },
          "reason": {
            "type": "string",
            "description": "invalid only; for an imported package with `rechecked: false`, why it was not checked again (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": "Presence of manifest.json.ots only; the proof is not checked here.",
                    "enum": [
                      "present",
                      "not_present"
                    ]
                  }
                }
              },
              "eidas_disig": {
                "type": "object",
                "properties": {
                  "status": {
                    "type": "string",
                    "description": "Presence of manifest.json.tsr. The token itself is checked in checks.eidas. missing: manifest.json records eidas_requested (the package was issued with the token), but manifest.json.tsr is not in the package.",
                    "enum": [
                      "present",
                      "missing"
                    ]
                  }
                },
                "nullable": true,
                "description": "null when there is no manifest.json.tsr and manifest.json does not record eidas_requested."
              },
              "proofsnap_issuance": {
                "type": "object",
                "properties": {
                  "status": {
                    "type": "string",
                    "description": "issued_by_proofsnap: the manifest hash is in the ProofSnap issuance ledger. unverified: it is not (packages issued before the ledger existed are not in it). unknown: the lookup failed.",
                    "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 when there is no manifest.json.tsr and manifest.json does not record eidas_requested. The eIDAS timestamp token as checked by ProofSnap, on request, from the stored package. This is a check performed by ProofSnap; the note field says how to verify the token independently.",
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "verified",
                      "verified_revocation_unknown",
                      "failed",
                      "unavailable",
                      "missing"
                    ],
                    "description": "missing: manifest.json records eidas_requested: true, so the package was issued with a qualified timestamp, but manifest.json.tsr is not in it (the response then carries the EIDAS_TOKEN_MISSING warning). verified: every check passed and the TSA certificate status recorded at issuance is known (good, or revoked after the token time for a reason other than key compromise). verified_revocation_unknown: every check that ran passed, but the TSA certificate status could not be established from the recorded data (revocation.status unknown or not_checked). failed: at least one check failed, see failures (the response then carries the EIDAS_TOKEN_FAILED warning). unavailable: no verdict on the token (reason: not configured, timeout, input_rejected when the verifier refused the input such as oversized validation data, inconclusive); nothing is implied about the token."
                  },
                  "reason": {
                    "type": "string",
                    "description": "unavailable only: timeout, input_rejected, inconclusive, or a short explanation."
                  },
                  "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": "The time in the token (TSTInfo genTime)."
                  },
                  "tsa_name": {
                    "type": "string",
                    "nullable": true,
                    "description": "From the signer certificate (common name and organization), never a default."
                  },
                  "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": "The time the chain was checked as of: the token's genTime, so a certificate that expired after the token was issued does not fail it."
                  },
                  "chain": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "subject": {
                          "type": "string"
                        },
                        "sha256": {
                          "type": "string"
                        }
                      }
                    }
                  },
                  "revocation": {
                    "type": "object",
                    "description": "Status of the TSA certificate from the data recorded at issuance (basis recorded_at_issuance), verified by signature and required to have been current at genTime. Not fetched live.",
                    "properties": {
                      "status": {
                        "type": "string",
                        "enum": [
                          "good",
                          "revoked",
                          "revoked_after_gen_time",
                          "unknown",
                          "not_checked"
                        ],
                        "description": "revoked_after_gen_time: revoked after the token time for a reason other than key compromise (the token stays verified). not_checked: no revocation data was recorded or checked."
                      },
                      "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": "Why the status is unknown."
                      }
                    }
                  },
                  "checks": {
                    "type": "object",
                    "description": "Each check: passed, failed, not_applicable (nonce: none recorded for a stored token), unknown (revocation without usable data) or not_checked (an earlier failure stopped the check).",
                    "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": "input_rejected only: what the verifier refused."
                  }
                }
              }
            },
            "description": "Absent when verdict is invalid. The verdict depends only on manifest_signature and declared_files; eidas is reported next to it and, when it failed, in warnings."
          },
          "warnings": {
            "type": "array",
            "description": "Present only when something outside the verdict needs attention. EIDAS_TOKEN_FAILED: manifest.json.tsr did not pass the check in checks.eidas. EIDAS_TOKEN_MISSING: manifest.json records eidas_requested, but manifest.json.tsr is not in the package.",
            "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": "Page to capture, validated like CaptureRequest.url.",
            "format": "uri",
            "maxLength": 2048
          },
          "mode": {
            "type": "string",
            "description": "interval: every interval_seconds. cron: on cron_expr. on_change: check the page every interval_seconds and capture only when its text changed.",
            "enum": [
              "interval",
              "cron",
              "on_change"
            ]
          },
          "interval_seconds": {
            "type": "integer",
            "description": "Required for interval and on_change. At least the plan minimum (300 on developer, startup, growth, custom, legal_solo and legal_seats; 86400 on website_archive), otherwise 400 INTERVAL_TOO_SHORT.",
            "minimum": 300
          },
          "cron_expr": {
            "type": "string",
            "description": "Required for cron (at most 200 characters). An unparsable expression returns 400 INVALID_CRON; two consecutive runs closer than the plan minimum return 400 INTERVAL_TOO_SHORT.",
            "maxLength": 200,
            "example": "0 9 * * 1-5"
          },
          "timezone": {
            "type": "string",
            "description": "IANA timezone used to evaluate cron_expr. Default UTC.",
            "example": "Europe/Berlin"
          },
          "webhook_url": {
            "type": "string",
            "description": "https URL. interval and cron runs send capture.completed or capture.failed; on_change sends monitor.change_detected. The URL must be https and must not point to a private, loopback, link-local, carrier-grade NAT or cloud-metadata address (the same rules as a capture URL; 422 SSRF_BLOCKED otherwise). An empty or whitespace-only value means no webhook. A schedule has one webhook_url, at the top level: an options.webhook_url is not stored (when no top-level webhook_url is sent it is used as the top-level one), so an on_change schedule never also sends capture.completed. Needs an API key: sent from a dashboard session it is refused with 400 WEBHOOK_REQUIRES_API_KEY, because webhooks are signed with the key's webhook secret.",
            "format": "uri"
          },
          "js_render": {
            "type": "boolean",
            "description": "on_change only: check the page with a full browser render instead of a plain HTTP fetch (for pages that need JavaScript).",
            "default": false
          },
          "options": {
            "$ref": "#/components/schemas/CaptureOptions"
          },
          "max_capture_age_seconds": {
            "type": "integer",
            "nullable": true,
            "minimum": 86400,
            "maximum": 34560000,
            "description": "on_change only: also take a full capture (it sends capture.completed) when the newest capture of the schedule is older than this, even if the page did not change. For rules that ask for a copy at least every N days, e.g. 7776000 for 90 days. From 1 day to 400 days and longer than interval_seconds; null (default) captures on a change only. PATCH with null removes it."
          },
          "alert_on": {
            "type": "string",
            "enum": [
              "any",
              "text"
            ],
            "nullable": true,
            "description": "on_change only (2026-10-14): which detected change sends monitor.change_detected. any (default, the behaviour before): every change capture. text: only when the comparison with the previous capture finds the visible text changed (diff_summary.text_verdict changed); a change capture whose comparison says the text did not change or could not be determined sends nothing (the capture is still taken, billed and sends capture.completed to subscriptions). Without a comparison result (no baseline, the comparison failed) it alerts. 400 MALFORMED_REQUEST with another mode or value. null for interval and cron schedules."
          }
        },
        "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": "The schedule's webhook (for an older schedule that kept it in options.webhook_url, that value is shown here and options never contains it). Needs an API key: sent from a dashboard session it is refused with 400 WEBHOOK_REQUIRES_API_KEY, because webhooks are signed with the key's webhook secret."
          },
          "options": {
            "type": "object",
            "description": "Capture options with defaults applied."
          },
          "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": "Why the schedule was paused automatically: max_failures (5 consecutive failed runs), no_billing, plan_downgrade (plan limits, or an Archive only plan), invalid_cron or key_revoked. null when active or paused by you.",
            "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": "The newest COMPLETED capture of the schedule (its current version; last_capture_id is the newest one dispatched)."
          },
          "current_captured_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "alert_on": {
            "type": "string",
            "enum": [
              "any",
              "text"
            ],
            "nullable": true,
            "description": "on_change: any or text (any for a schedule created before the option); null for interval and 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": "Entries in the custody log."
          },
          "custody_head_hash": {
            "type": "string",
            "description": "sha256:<hex> of the newest custody log entry.",
            "nullable": true
          },
          "seal": {
            "$ref": "#/components/schemas/MatterSeal",
            "description": "null until sealed."
          },
          "member_uids": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "created_by": {
            "type": "string",
            "description": "The owner.",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "read_only": {
            "type": "boolean",
            "description": "Present (true) on a matter of a company that has ended or is kept as a company archive: open, export and share revocation only."
          },
          "legal_hold": {
            "type": "boolean",
            "description": "The matter's legal hold is on (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>: the custody head that was sealed and submitted to OpenTimestamps."
          },
          "ots": {
            "type": "string",
            "description": "OpenTimestamps proof, hex."
          },
          "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": "The token passed the ProofSnap check at sealing (shared verifier, same checks as a capture token)."
              },
              "revocation_status": {
                "type": "string",
                "enum": [
                  "good",
                  "revoked_after_gen_time",
                  "unknown",
                  "not_checked"
                ],
                "description": "TSA certificate status established at sealing from the OCSP response or CRL collected then."
              },
              "validation": {
                "type": "object",
                "nullable": true,
                "description": "The eidas_validation.json content recorded at sealing, for an offline check of the token: tsaCertChain, rootCaPem, ocspResponse, crlResponse (dropped and named in omitted when too large), ocspUrl, ocspFetchedAt, ocspStatus, crlUrl, verification. Seals made before 2026-10-01 have none.",
                "additionalProperties": true
              }
            },
            "nullable": true,
            "description": "eIDAS timestamp over the same hash when the deployment has eIDAS configured, the caller is live and a token was obtained and passed the check, else null (see 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": "Absent on seals made before 2026-10-01. not_obtained: the seal carries the OpenTimestamps anchor only (eidas_reason token_refused, unavailable or test_key). A timeout of the eIDAS stamp never produces a seal: the request returns 503 EIDAS_SEAL_RETRY instead."
          },
          "eidas_reason": {
            "type": "string",
            "enum": [
              "token_refused",
              "unavailable",
              "test_key",
              "account_limit"
            ],
            "description": "not_obtained only. test_key: sealed by a test caller (test API key or sandbox session), so no eIDAS timestamp was requested. account_limit: the account has no paid invoice yet and reached its eIDAS limit (20 a UTC day, 50 a UTC month)."
          }
        },
        "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": "For example matter.created, matter.updated, capture.added, capture.removed, capture.legal_hold_placed, capture.legal_hold_released, member.added, member.removed, package.imported (an evidence package imported with matter_id; target is the capture id)."
          },
          "target": {
            "type": "string",
            "nullable": true
          },
          "prev_hash": {
            "type": "string"
          },
          "entry_hash": {
            "type": "string"
          },
          "details": {
            "type": "object",
            "description": "package.imported only: manifest_sha256, zip_sha256, package_kind, verdict, levels. Stored with the entry but NOT covered by entry_hash.",
            "additionalProperties": true
          }
        },
        "description": "One link of the hash chain. entry_hash covers seq, ts, actor, action, target and 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": "For example capture.downloaded, export.generated, share.created, share.revoked."
          },
          "target": {
            "type": "string",
            "nullable": true
          },
          "source_ip": {
            "type": "string",
            "nullable": true,
            "description": "Matter owner only (absent for members)."
          },
          "user_agent": {
            "type": "string",
            "nullable": true,
            "description": "Matter owner only (absent for members)."
          }
        },
        "description": "Access event. Not part of the hash chain."
      },
      "CertifyResult": {
        "type": "object",
        "properties": {
          "certify_id": {
            "type": "string",
            "example": "cert_a1B2c3D4e5F6g7H8"
          },
          "status": {
            "type": "string",
            "enum": [
              "completed"
            ]
          },
          "hash_sha256": {
            "type": "string",
            "description": "Single certificate only."
          },
          "combined": {
            "type": "boolean",
            "description": "Combined certificate only."
          },
          "file_count": {
            "type": "integer",
            "description": "Combined certificate only."
          },
          "files": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "name": {
                  "type": "string",
                  "description": "The label you sent.",
                  "nullable": true
                },
                "sha256": {
                  "type": "string"
                }
              }
            },
            "description": "Combined certificate only."
          },
          "label": {
            "type": "string",
            "description": "Your label, or \"N files\" for a combined certificate.",
            "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 is in the package and waits for a Bitcoin block. not_present: the OpenTimestamps calendars could not be reached, the package has no manifest.json.ots and manifest.json does not list it."
                  },
                  "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": "Link to the public ProofSnap verifier.",
            "format": "uri"
          },
          "warnings": {
            "$ref": "#/components/schemas/Rule902DisabledWarnings"
          }
        }
      },
      "PlainText": {
        "type": "string",
        "description": "Plain-text rule for short free-text fields that are printed on court-facing documents or public pages (case_reference, case_id, case_note, evidence_pack.custodian values, the certify-hash case_reference, the redaction custodian, the export declaration, company and matter names). The value is NFC-normalised, whitespace is collapsed and trimmed, and the length is counted in characters (Unicode code points). Refused: control characters (C0, C1), bidi embeddings, overrides and isolates (U+202A to U+202E, U+2066 to U+2069), direction marks (U+200E, U+200F, U+061C), zero-width and invisible characters (U+200B, U+2060 to U+2064, U+FEFF, U+00AD, U+034F, U+180E, U+206A to U+206F, U+FFF9 to U+FFFB), the Hangul filler characters (U+115F, U+1160, U+3164, U+FFA0), tag characters (U+E0000 to U+E007F), lone surrogates and private-use characters. Letters of every script pass, including Hangul syllables, Arabic, Hebrew, Devanagari and CJK, and U+200C / U+200D (ZWNJ, ZWJ) are allowed because correct spelling in several scripts needs them."
      },
      "EidasTimestamp": {
        "type": "object",
        "description": "eIDAS outcome when include_eidas was requested on a live key; absent when it was not requested. confirmed: the package carries manifest.json.tsr (billed, unless revocation is unknown). not_obtained: no token was shipped and nothing was billed; the package carries the OpenTimestamps anchor only (reason token_refused: a token came back and did not pass the check at issuance; unavailable: no token could be obtained; time_budget: the request did not fit in the time left, so it was not made, was refused by the timestamp service before any request to the authority, or was stopped, which happens on the synchronous redaction route or at the end of a long capture; account_limit: the account has no paid invoice yet and reached its eIDAS limit of 20 stamps a UTC day or 50 a UTC month, counted over captures, batches, schedules, certifications, comparisons, redactions and matter seals together, so no request was made; accounts with a paid invoice are not limited). Rows completed before 2026-10-01 report only confirmed.",
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "confirmed",
              "not_obtained"
            ]
          },
          "reason": {
            "type": "string",
            "enum": [
              "token_refused",
              "unavailable",
              "time_budget",
              "account_limit"
            ],
            "description": "not_obtained only."
          },
          "serial": {
            "type": "string",
            "nullable": true,
            "description": "confirmed only."
          },
          "issued_at": {
            "type": "string",
            "nullable": true,
            "description": "confirmed only."
          },
          "revocation": {
            "type": "string",
            "enum": [
              "unknown"
            ],
            "description": "confirmed only, and only when the TSA certificate status could not be established at issuance (the token passed every other check). The token ships, and is not billed."
          },
          "billed": {
            "type": "boolean",
            "enum": [
              false
            ],
            "description": "Present (false) together with revocation unknown: this token was not billed."
          }
        }
      },
      "ExternalArchive": {
        "type": "object",
        "description": "The Internet Archive (Wayback Machine) result of a capture. The Internet Archive is a public third-party archive.",
        "required": [
          "requested"
        ],
        "properties": {
          "requested": {
            "type": "boolean",
            "description": "false when no Internet Archive request was made (external_archive: false, a certification or comparison, a capture made before archiving existed, or the request did not fit the time budget). All other fields are then absent."
          },
          "status": {
            "type": "string",
            "enum": [
              "created",
              "recent_existing",
              "created_after_request",
              "existing",
              "pending",
              "rate_limited",
              "failed",
              "unconfirmed",
              "unavailable",
              "skipped",
              "skipped_sensitive",
              "skipped_query"
            ],
            "description": "Current result. For a Save Page Now job still pending at sealing, the scheduler reads the job status about 5 and 30 minutes after the request and this becomes the final outcome (created, recent_existing, failed, rate_limited, or unconfirmed when no answer came within the check window). created, recent_existing, created_after_request and existing carry 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": "The state sealed in the package (metadata.json, forensic_log.json, chain_of_custody.json and evidence.pdf record it). The package itself never changes; equals status unless a follow-up resolved a pending job."
          },
          "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": "The public Wayback Machine copy, always https://web.archive.org/web/{snapshot_timestamp}/{url}."
          },
          "snapshot_timestamp": {
            "type": "string",
            "nullable": true,
            "description": "Wayback timestamp YYYYMMDDhhmmss (UTC)."
          },
          "snapshot_offset_seconds": {
            "type": "integer",
            "nullable": true,
            "description": "Snapshot time minus the capture time, in seconds (negative: before the capture)."
          },
          "resolved_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "When the scheduler's follow-up recorded the final result; null when the sealed state was final."
          },
          "job_id": {
            "type": "string",
            "nullable": true,
            "description": "Save Page Now job id (authenticated requests)."
          },
          "follow_up_pending": {
            "type": "boolean",
            "description": "true while the scheduler still has to read the job status."
          },
          "skip_reason": {
            "type": "string",
            "nullable": true,
            "description": "Why the URL was not sent, when status is skipped, skipped_sensitive or skipped_query: credentials_in_url, sensitive_path, opaque_path_segment, sensitive_fragment, sensitive_parameter_name, opaque_parameter_value, query_string (or the status itself on older rows)."
          },
          "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 or pending as read from the Save Page Now job, null when it could not be read."
              },
              "snapshot_url": {
                "type": "string",
                "nullable": true,
                "description": "https://web.archive.org/web/<14 digits>/<URL>, null when absent or not valid."
              },
              "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": "Why there is no job answer: no_request_time (the row has no request time to schedule the reads from), capture_deleted or capture_failed (the capture was trashed or failed while the job was pending, so it was not followed up; a capture restored from the trash within 30 minutes of the request is followed up again). null otherwise."
              }
            },
            "description": "The follow-up record (history), as a fixed projection: snapshot_url only when it is a valid Wayback snapshot URL of snapshot_timestamp; reason only when the follow-up ended without a job answer."
          }
        }
      },
      "CaptureStorage": {
        "type": "object",
        "description": "Retention, the duplicate copy and (scheduled captures) the version history.",
        "properties": {
          "retention": {
            "type": "object",
            "properties": {
              "years": {
                "type": "integer",
                "example": 10
              },
              "counted_from": {
                "type": "string",
                "format": "date-time",
                "nullable": true,
                "description": "The later of created_at and the last known use: the last check that found the page unchanged, or the moment a newer capture of the same schedule replaced this one."
              },
              "earliest_deletion_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true,
                "description": "counted_from plus years, approximate: the deletion runs on a later scheduler sweep, and later checks can move it. null under a legal hold."
              },
              "legal_hold": {
                "type": "boolean"
              }
            }
          },
          "duplicate_copy": {
            "type": "object",
            "properties": {
              "status": {
                "type": "string",
                "enum": [
                  "none",
                  "pending",
                  "stored",
                  "not_copied"
                ],
                "description": "stored: a second copy of the package (and its upgraded OTS proof) is kept in a separate bucket in another EU region and deleted with the original. pending: being copied (minutes). none: test packages and packages from before the duplicate existed until they are copied."
              },
              "copied_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              }
            }
          },
          "version": {
            "type": "object",
            "description": "Scheduled captures only.",
            "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": "First check (on_change) that found the page text unchanged since this capture."
              },
              "unchanged_last_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              },
              "unchanged_checks": {
                "type": "integer",
                "description": "Recorded unchanged checks (at most one an hour)."
              }
            }
          }
        }
      },
      "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": "An event of the catalog (tag Webhooks)."
      },
      "WebhookSubscriptionFilters": {
        "type": "object",
        "description": "At most one of host and schedule_id. The subscription then gets only the events of rows with that host (lower case hostname of the captured URL) or of that schedule. `{}` for none.",
        "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": "https endpoint the events are POSTed to."
          },
          "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": "The environment of the credential that created it. A live subscription gets the events of live rows only, a test subscription those of test rows only."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "created_by": {
            "type": "string",
            "enum": [
              "key",
              "session"
            ],
            "description": "key: created with an API key; revoking that key disables the subscription (disabled_reason key_revoked)."
          },
          "consecutive_failures": {
            "type": "integer",
            "description": "Failed deliveries since the last success."
          },
          "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": "Error class of the last failed delivery (timeout, connection_failed, blocked_address, redirect_not_followed, http_<status>)."
          },
          "disabled_reason": {
            "type": "string",
            "nullable": true,
            "enum": [
              "gone",
              "failing",
              "key_revoked",
              null
            ],
            "description": "gone: the endpoint responded with 410. failing: every delivery failed for 5 days without a success. key_revoked: the API key that created it was revoked. PATCH status active reactivates it."
          },
          "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": "https endpoint the events are POSTed to."
          },
          "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": "The environment of the credential that created it. A live subscription gets the events of live rows only, a test subscription those of test rows only."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "created_by": {
            "type": "string",
            "enum": [
              "key",
              "session"
            ],
            "description": "key: created with an API key; revoking that key disables the subscription (disabled_reason key_revoked)."
          },
          "secret": {
            "type": "string",
            "description": "whsec_ followed by standard base64. Shown ONCE: every delivery to this subscription is signed with it (Standard Webhooks). Store it now; it cannot be read back. An Idempotency-Key replay within 24 hours returns the same body, secret included."
          }
        },
        "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": "The event body as subscriptions receive it, without subscription_id and without presigned links (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": "Your private note on the capture (for example \"mobile capture\"), at most 200 characters, plain text (the PlainText rule). It is stored on the capture record only: it is NOT part of the sealed evidence package (not in the ZIP, the PDF, the manifest or any hash) and carries no evidentiary weight, so it can be changed or cleared later with PATCH /v1/captures/{capture_id} without affecting the evidence. Only the capture's maker reads it. Omit it, or send null or an empty string, for no note.",
        "example": "mobile capture"
      },
      "CaptureEvent": {
        "type": "object",
        "description": "One event of the account's capture event chain. hash = SHA-256 (hex) of the canonical JSON of the hashed fields (v, chain, seq, type, capture_id, manifest_sha256, actor, access, detail, at, prev_hash; keys sorted, no whitespace, absent values as null). prev_hash is the previous event's hash, 64 zeros for seq 1.",
        "properties": {
          "v": {
            "type": "integer",
            "enum": [
              1
            ]
          },
          "chain": {
            "type": "string",
            "description": "The account: the uid for personal captures, org_<org_id> for a company's captures."
          },
          "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 of the capture's manifest.json, read before any deletion."
          },
          "actor": {
            "type": "string",
            "description": "The acting account's uid, or system for the server's sweeps."
          },
          "access": {
            "type": "string",
            "description": "author, company_owner, matter_owner or system."
          },
          "detail": {
            "type": "string",
            "nullable": true,
            "description": "legal_hold_on / legal_hold_off: the hold's source (manual, bulk, matter:<id>, derived:<capture_id>), or for a release by the system (actor system) its reason code subscription_ended; s3_legal_hold: the S3 result (on, off, failed, unsupported, none); import: the kind of the imported package. No free text."
          },
          "at": {
            "type": "string",
            "format": "date-time",
            "description": "When the event was written to the chain. Never earlier than the previous event's at."
          },
          "prev_hash": {
            "type": "string"
          },
          "hash": {
            "type": "string"
          },
          "occurred_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the action itself was committed (the state change's transaction). Not hashed. Normally equal to at within milliseconds; later only when the chain append was retried by the server (the action is recorded in the same transaction as the change, so none is lost)."
          }
        }
      },
      "CaptureEventAnchor": {
        "type": "object",
        "description": "A daily OpenTimestamps anchor of the chain: head_hash is the hash of event to_seq, which commits to every earlier event.",
        "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": "The OpenTimestamps proof (.ots file, base64) over head_hash."
          },
          "ots_status": {
            "type": "string",
            "enum": [
              "pending",
              "bitcoin",
              "unconfirmed"
            ],
            "description": "pending: ots_proof_base64 holds the calendars' commitment (ots verify asks the calendars); bitcoin: it was replaced by the proof upgraded to the Bitcoin block (bitcoin_block_height); unconfirmed: no Bitcoin proof was returned within 14 days, the stored proof still verifies through the calendars."
          },
          "bitcoin_block_height": {
            "type": "integer",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ImportSummary": {
        "type": "object",
        "description": "The three levels of a verified package.",
        "properties": {
          "kind_label_key": {
            "type": "string",
            "description": "Label key of the package kind, 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": "When the extension registry recorded the package (provenance registered_by_extension_account)."
          },
          "issued_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the API ledger recorded the issuance (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": "uploading only: complete before this time (24 hours after the create)."
          },
          "finished_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the import reached its final status."
          },
          "capture_id": {
            "type": "string",
            "description": "accepted only: the new capture (the same id as the import)."
          },
          "duplicate_of": {
            "type": "string",
            "description": "duplicate only: your capture that already holds this manifest."
          },
          "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": "The package kind, once detected."
          },
          "summary": {
            "$ref": "#/components/schemas/ImportSummary"
          },
          "reasons": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "rejected and expired: reason codes, for example 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. Codes only, never file names."
          },
          "zip_sha256": {
            "type": "string",
            "description": "SHA-256 of the uploaded ZIP, once computed."
          },
          "manifest_sha256": {
            "type": "string",
            "description": "SHA-256 of the package's manifest.json (accepted and duplicate)."
          },
          "matter_id": {
            "type": "string",
            "description": "The matter named when the import was created."
          },
          "matter_assignment": {
            "type": "string",
            "enum": [
              "assigned",
              "skipped"
            ],
            "description": "accepted with matter_id: whether the capture was added to the matter (skipped when the matter was sealed, archived or no longer open to you by then; the import stays accepted)."
          },
          "upload": {
            "type": "object",
            "description": "Only with ?upload=true while status is uploading.",
            "properties": {
              "upload_id": {
                "type": "string"
              },
              "part_size": {
                "type": "integer"
              },
              "parts": {
                "type": "array",
                "description": "Presigned PUT URLs of the parts not received yet; PUT exactly 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": "The upload was already completed: send complete again."
              }
            }
          }
        }
      },
      "ImportVerification": {
        "type": "object",
        "description": "The verification report recorded at the import (no file contents, no free-text details).",
        "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": "The id the package carries (ps_..., FC-..., cap_..., cert_..., cmp_..., red_...)."
          },
          "manifest_sha256": {
            "type": "string"
          },
          "zip_sha256": {
            "type": "string"
          },
          "anchor_sha256": {
            "type": "string",
            "description": "The hash the timestamps cover: the manifest's, or for a single file certification the certified file's."
          },
          "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": "Only on a capture with 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": "captured_at of an imported package is the time the package states, never a time ProofSnap observed."
          },
          "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": "Present only when the request asked for the certification template (include_rule_902: true, or for a capture a non-empty evidence_pack.custodian) while the template is disabled. The request was accepted and the flag ignored: no rule_902_certification.pdf is generated.",
        "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"
          ]
        }
      }
    }
  }
}
