{
  "openapi": "3.1.0",
  "info": {
    "title": "Unwhisper API",
    "version": "2.4.3",
    "description": "Scan text or a file for prompt injection and act on the PASS/FAIL verdict before the content reaches an LLM.\n\nEvery route needs an API key (`X-API-Key`, or `Authorization: Bearer`). Credit accounting and rate-limit state ride in response headers, documented per response below.\n\nNote on the scan result: `model_version`, `model_sha256`, `models_run` and `model_scores` describe the engine that produced a verdict. They are informational, not part of the contract, and may change or be withdrawn — do not build on them."
  },
  "servers": [
    {
      "url": "https://api.unwhisper.ai",
      "description": "Hosted API"
    }
  ],
  "security": [
    {
      "ApiKeyAuth": []
    },
    {
      "BearerAuth": []
    }
  ],
  "paths": {
    "/api/v2/scan": {
      "post": {
        "tags": [
          "v2"
        ],
        "summary": "Scan raw bytes (text or binary) for prompt injection",
        "description": "Scan a raw octet-stream body (text or binary) for prompt injection.\n\nAuth: X-API-KEY header (v2 is API-key-only; Basic Auth is not accepted).\n\nExample:\n    ```\n    POST /v2/scan?fast_fail=false&filename=report.pdf\n    X-API-KEY: <key>\n    Content-Type: application/octet-stream\n    <raw bytes>\n    ```",
        "operationId": "scan_v2_v2_scan_post",
        "parameters": [
          {
            "name": "confidence_threshold",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "number",
                  "maximum": 1,
                  "minimum": 0
                },
                {
                  "type": "null"
                }
              ],
              "description": "Override the default malicious-confidence threshold.",
              "title": "Confidence Threshold"
            },
            "description": "Override the default malicious-confidence threshold."
          },
          {
            "name": "ocr",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "description": "Images: OCR visible text (default, max coverage). False = metadata + steg-LSB only (faster, weaker).",
              "default": true,
              "title": "Ocr"
            },
            "description": "Images: OCR visible text (default, max coverage). False = metadata + steg-LSB only (faster, weaker)."
          },
          {
            "name": "ocr_scanned_pdf_pages",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "description": "PDFs: render + OCR scanned (no-text-layer) pages. ON by default for maximum coverage (bounded to 20 pages, ~1s/page; set false to disable for speed).",
              "default": true,
              "title": "Ocr Scanned Pdf Pages"
            },
            "description": "PDFs: render + OCR scanned (no-text-layer) pages. ON by default for maximum coverage (bounded to 20 pages, ~1s/page; set false to disable for speed)."
          },
          {
            "name": "fast_fail",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "description": "Stop at the first malicious chunk (production default). False = classify every region (audit mode). NOTE: asking for forensic_report=true switches this to false unless you set it explicitly — a work-up of a partially-classified scan is misleading. See forensic_report.",
              "default": true,
              "title": "Fast Fail"
            },
            "description": "Stop at the first malicious chunk (production default). False = classify every region (audit mode). NOTE: asking for forensic_report=true switches this to false unless you set it explicitly — a work-up of a partially-classified scan is misleading. See forensic_report."
          },
          {
            "name": "filename",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "description": "Advisory original filename — used for the temp suffix and audit findings only. Dispatch is by sniffed content, never the extension.",
              "title": "Filename"
            },
            "description": "Advisory original filename — used for the temp suffix and audit findings only. Dispatch is by sniffed content, never the extension."
          },
          {
            "name": "include_canonical",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "description": "Include the canonical_text extra in the response (default off). The classifier/audit view: canonicalised text with [source] region markers + appended decoded payloads.",
              "default": false,
              "title": "Include Canonical"
            },
            "description": "Include the canonical_text extra in the response (default off). The classifier/audit view: canonicalised text with [source] region markers + appended decoded payloads."
          },
          {
            "name": "include_forward",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "description": "Include the forward_text extra in the response (default off). Clean, ready-to-forward content for a receiving LLM (visible regions only, markers + decoded payloads removed).",
              "default": false,
              "title": "Include Forward"
            },
            "description": "Include the forward_text extra in the response (default off). Clean, ready-to-forward content for a receiving LLM (visible regions only, markers + decoded payloads removed)."
          },
          {
            "name": "forensic_report",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "description": "When the verdict is FAIL, generate a self-contained HTML forensic work-up of everything extracted (all regions, rendered steg bit-planes, PDF page/embedded images, archive-member tree) and return a short-lived SIGNED URL (forensic_report_url) to fetch it. The report auto-deletes after ~5 min. Requires the deployment master switch FORENSIC_REPORT_ENABLED. IMPLIES fast_fail=false unless you pass fast_fail explicitly: the report renders the whole document but can only flag what was classified, so a fast-failed work-up shows unflagged injection text next to a detections table that reads as exhaustive. Pass fast_fail=true to override (the report then carries a partial-classification banner).",
              "default": false,
              "title": "Forensic Report"
            },
            "description": "When the verdict is FAIL, generate a self-contained HTML forensic work-up of everything extracted (all regions, rendered steg bit-planes, PDF page/embedded images, archive-member tree) and return a short-lived SIGNED URL (forensic_report_url) to fetch it. The report auto-deletes after ~5 min. Requires the deployment master switch FORENSIC_REPORT_ENABLED. IMPLIES fast_fail=false unless you pass fast_fail explicitly: the report renders the whole document but can only flag what was classified, so a fast-failed work-up shows unflagged injection text next to a detections table that reads as exhaustive. Pass fast_fail=true to override (the report then carries a partial-classification banner)."
          }
        ],
        "responses": {
          "200": {
            "description": "Scan completed; verdict in body.status (PASS|FAIL|ERROR). ERROR = unassessable short/glued input — treat as block; see body.error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ScanResponse"
                }
              }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "schema": {
                  "type": "integer"
                },
                "description": "Burst capacity."
              },
              "X-RateLimit-Remaining": {
                "schema": {
                  "type": "integer"
                },
                "description": "Requests left of that capacity."
              },
              "X-RateLimit-Reset": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds until Remaining is back up to Limit."
              },
              "X-RateLimit-Policy": {
                "schema": {
                  "type": "string"
                },
                "description": "Sustained quota, as `<quota>;w=<window seconds>;burst=<capacity>` — e.g. `10;w=60;burst=20`."
              },
              "X-Credit-Cost": {
                "schema": {
                  "type": "integer"
                },
                "description": "Credits this scan cost — settled from the work the engine reports, so a 10-page PDF costs more than a 1-page one. Never charged on failure."
              },
              "X-Credits-Remaining": {
                "schema": {
                  "type": "string"
                },
                "description": "Balance after the scan, or \"unmetered\" for a comped account."
              },
              "X-Credit-Breakdown": {
                "schema": {
                  "type": "string"
                },
                "description": "JSON unit counts the charge was built from — {text, images, documents, pages, embedded_images} — the engine's `work` block. Text 1 · image 4 · document 3 including its first page then 1 per additional page, + 4 per embedded image; archive members included, the archive itself free."
              }
            }
          },
          "400": {
            "description": "Empty request body"
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "Insufficient credits. Nothing was scanned and nothing was charged.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Your plan does not include this feature.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "Body larger than 25 MB.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited. Retry after the number of seconds in Retry-After; the request was not charged.",
            "headers": {
              "X-RateLimit-Limit": {
                "schema": {
                  "type": "integer"
                },
                "description": "Burst capacity."
              },
              "X-RateLimit-Remaining": {
                "schema": {
                  "type": "integer"
                },
                "description": "Requests left of that capacity."
              },
              "X-RateLimit-Reset": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds until Remaining is back up to Limit."
              },
              "X-RateLimit-Policy": {
                "schema": {
                  "type": "string"
                },
                "description": "Sustained quota, as `<quota>;w=<window seconds>;burst=<capacity>` — e.g. `10;w=60;burst=20`."
              },
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds to wait."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Scan failed"
          },
          "502": {
            "description": "The scan failed upstream. Our fault, not your request — never charged.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "The scanner is unavailable. Our fault, not your request — never charged.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "504": {
            "description": "The scan timed out. Our fault, not your request — never charged.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/octet-stream": {
              "schema": {
                "type": "string",
                "format": "binary"
              }
            }
          }
        },
        "security": [
          {
            "ApiKeyAuth": []
          },
          {
            "BearerAuth": []
          }
        ]
      }
    },
    "/api/v2/livez": {
      "get": {
        "tags": [
          "v2"
        ],
        "summary": "Liveness probe — is the process up?",
        "description": "Liveness: dependency-free, no auth, always 200. A failure here means the\nprocess is wedged and should be restarted. Does NOT check the model — that\nis readiness's job (a model blip must not trigger a restart loop).",
        "operationId": "livez_v2_v2_livez_get",
        "responses": {
          "200": {
            "description": "Process is alive",
            "content": {
              "application/json": {
                "schema": {}
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKeyAuth": []
          },
          {
            "BearerAuth": []
          }
        ]
      }
    },
    "/api/v2/readyz": {
      "get": {
        "tags": [
          "v2"
        ],
        "summary": "Readiness probe — can we serve traffic?",
        "description": "Readiness: no auth, status-code-driven. 200 when the model is loaded,\n503 otherwise, so a load balancer / k8s readiness probe can decide on the\nHTTP code alone without parsing the body.",
        "operationId": "readyz_v2_v2_readyz_get",
        "responses": {
          "200": {
            "description": "Model loaded; ready to serve",
            "content": {
              "application/json": {
                "schema": {}
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Model not loaded; not ready (do not route traffic)"
          }
        },
        "security": [
          {
            "ApiKeyAuth": []
          },
          {
            "BearerAuth": []
          }
        ]
      }
    },
    "/api/v2/forensic/{token}/{asset}": {
      "get": {
        "summary": "Fetch a forensic report",
        "description": "Serves the report named by a forensic_report_url from a FAIL verdict. Deliberately unauthenticated: the short-lived signed token IS the authorisation, which is what lets the link open in a browser. Expires a few minutes after the scan.",
        "security": [],
        "parameters": [
          {
            "name": "token",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The signed token from forensic_report_url."
          },
          {
            "name": "asset",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "default": "report.html"
            },
            "description": "Asset within the report; the report itself is report.html."
          }
        ],
        "responses": {
          "200": {
            "description": "The report or one of its assets.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "404": {
            "description": "Unknown, expired, or tampered token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key"
      },
      "BearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "The same API key, as a bearer token."
      }
    },
    "schemas": {
      "Detection": {
        "properties": {
          "chunk_index": {
            "type": "integer",
            "title": "Chunk Index",
            "description": "Index of the chunk where detection occurred"
          },
          "start_token": {
            "type": "integer",
            "title": "Start Token",
            "description": "Starting token position in the document"
          },
          "end_token": {
            "type": "integer",
            "title": "End Token",
            "description": "Ending token position in the document"
          },
          "confidence": {
            "type": "number",
            "title": "Confidence",
            "description": "Final confidence used for FAIL/PASS decision (= probability_malicious + (1-probability_malicious)*boost)"
          },
          "probability_malicious": {
            "type": "number",
            "title": "Probability Malicious",
            "description": "Raw probability the deciding classifier returned for this chunk (the CNN or the encoder — see `model`)"
          },
          "boost": {
            "type": "number",
            "title": "Boost",
            "description": "Per-chunk boost applied because the source is unusual (e.g. EXIF/steg/decoded). 0.0 for plain body text.",
            "default": 0
          },
          "source": {
            "type": "string",
            "title": "Source",
            "description": "Where this chunk came from: 'body', 'exif:UserComment', 'ocr:on_photo', 'steg_lsb', 'decoded:base64', etc.",
            "default": "body"
          },
          "matched_text": {
            "type": "string",
            "title": "Matched Text",
            "description": "Full text of the chunk that triggered the detection"
          },
          "page_number": {
            "anyOf": [
              {
                "type": "integer"
              },
              {
                "type": "null"
              }
            ],
            "title": "Page Number",
            "description": "PDF page number (1-indexed) where detection occurred. Only present for PDF files."
          },
          "model": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Model",
            "description": "Which classifier flagged this chunk: 'cnn' (fast n-gram TextCNN), 'encoder' (bge-base semantic transformer), or 'cascade' (pass 3: a single sentence the CNN ranked as a candidate AND the encoder confirmed bare — the buried-injection tier; `matched_text` is that sentence). The dual-model engine fails a chunk if EITHER classifier flags it; the cascade fails a document only when BOTH agree on one sentence."
          },
          "cnn_probability": {
            "anyOf": [
              {
                "type": "number"
              },
              {
                "type": "null"
              }
            ],
            "title": "Cnn Probability",
            "description": "Cascade detections only: the CNN's probability for the flagged sentence (`probability_malicious` is the encoder's). Absent for 'cnn' / 'encoder' detections."
          }
        },
        "type": "object",
        "required": [
          "chunk_index",
          "start_token",
          "end_token",
          "confidence",
          "probability_malicious",
          "matched_text"
        ],
        "title": "Detection",
        "description": "Model for a single detection within scan results."
      },
      "ScanResponse": {
        "properties": {
          "status": {
            "type": "string",
            "title": "Status",
            "description": "PASS, FAIL, or ERROR. PASS = no injection detected. FAIL = injection detected (block). ERROR = the input could not be assessed — a SHORT input containing an unreadable/glued token the word-splitter could not resolve (see `error`), so it is neither a confident FAIL nor a safe PASS. Treat ERROR as a BLOCK (fail-closed): it is language-agnostic cover for glued/obfuscated short inputs. Added in API 2.4.3."
          },
          "error": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Error",
            "description": "Human-readable reason when status=ERROR (e.g. an unresolvable glued token in a short input); null for PASS/FAIL."
          },
          "scan_complete": {
            "type": "boolean",
            "title": "Scan Complete",
            "description": "False when the scan hit its work budget (SCAN_BUDGET_SEC / MAX_CHUNKS_PER_SCAN) or the client disconnected, and stopped classifying early — see the scan_budget_exceeded finding. By default an incomplete scan returns status=FAIL (SCAN_BUDGET_FAIL_CLOSED), because a firewall must not report clean about content it never inspected; the detection then carries source='scan_budget'. Where the deployment has turned that off, a PASS with scan_complete=false is NOT a clean result — treat it as inconclusive, retry or split the input.",
            "default": true
          },
          "total_chunks": {
            "type": "integer",
            "title": "Total Chunks",
            "description": "Total number of chunks in document"
          },
          "chunks_scanned": {
            "type": "integer",
            "title": "Chunks Scanned",
            "description": "Number of chunks actually scanned (fast-fail may stop early)"
          },
          "tokens_processed": {
            "type": "integer",
            "title": "Tokens Processed",
            "description": "Total number of tokens processed across all scanned chunks",
            "default": 0
          },
          "malicious_chunks": {
            "type": "integer",
            "title": "Malicious Chunks",
            "description": "Number of chunks flagged as malicious"
          },
          "binary_chunks_skipped": {
            "type": "integer",
            "title": "Binary Chunks Skipped",
            "description": "Number of binary chunks skipped",
            "default": 0
          },
          "short_chunks_skipped": {
            "type": "integer",
            "title": "Short Chunks Skipped",
            "description": "Number of CNN chunks under MIN_CHUNK_WORDS plus encoder windows under ENCODER_MIN_CHUNK_WORDS skipped (models unreliable on short text)",
            "default": 0
          },
          "max_confidence": {
            "type": "number",
            "title": "Max Confidence",
            "description": "Highest BOOSTED confidence across all chunks — what the FAIL/PASS decision uses"
          },
          "max_model_confidence": {
            "type": "number",
            "title": "Max Model Confidence",
            "description": "Highest RAW model probability across all chunks (before boost). Diagnostic; not used for the verdict.",
            "default": 0
          },
          "suspicion_score": {
            "type": "number",
            "title": "Suspicion Score",
            "description": "Document-level aggregate suspicion in [0,1], combining all wrapper findings (stripped invisibles, decoded payloads, image metadata/steg/OCR). Independent of the classifier verdict; callers may use as a secondary signal.",
            "default": 0
          },
          "detections": {
            "items": {
              "$ref": "#/components/schemas/Detection"
            },
            "type": "array",
            "title": "Detections",
            "description": "List of detection details"
          },
          "canonical_text": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Canonical Text",
            "description": "OPT-IN (include_canonical=true): input after Unicode canonicalisation (NFKC + strip of invisible/bidi/tag code points), with extracted regions wrapped in [source]…[/source] markers and decoded payloads appended. The classifier/audit view. null unless requested."
          },
          "findings": {
            "items": {
              "additionalProperties": true,
              "type": "object"
            },
            "type": "array",
            "title": "Findings",
            "description": "Non-fatal observations from preprocessing (stripped char counts, NFKC change, decoded payloads, image metadata/steg, etc.). Severity is wrapper hint, not verdict."
          },
          "work": {
            "anyOf": [
              {
                "additionalProperties": {
                  "type": "integer"
                },
                "type": "object"
              },
              {
                "type": "null"
              }
            ],
            "title": "Work",
            "description": "Billable units of work the engine performed: {text, images, documents, pages, embedded_images}. `text` = plain-text scans; `images` = standalone images run through the image pipeline (metadata, OCR, steganography); `documents` = PDF/Office/markup/code/binary inputs; `pages` = pages of those documents extracted or OCR'd; `embedded_images` = images inside documents that were rasterised and scanned. Summed across archive members (unpacking itself is free). A rejected/failed extraction reports zeros. Callers metering usage should bill from this block rather than from the declared content type."
          },
          "forward_text": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Forward Text",
            "description": "OPT-IN (include_forward=true): clean, ready-to-forward content for a receiving LLM — visible-content regions only, with markers removed and decoded payloads stripped. Feed THIS (not the original input) into downstream prompts. null unless requested. See docs/adr/0001-firewall-output-mode.md."
          },
          "model_version": {
            "type": "string",
            "title": "Model Version",
            "description": "Model directory name (e.g., 'model-0046')"
          },
          "model_sha256": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Model Sha256",
            "description": "SHA256 hash of the model file for exact traceability"
          },
          "models_run": {
            "anyOf": [
              {
                "items": {
                  "type": "string"
                },
                "type": "array"
              },
              {
                "type": "null"
              }
            ],
            "title": "Models Run",
            "description": "DIAGNOSTIC (only present when model=cnn|encoder): which classifiers actually scored this request. Absent in the default model=both response."
          },
          "model_scores": {
            "anyOf": [
              {
                "additionalProperties": true,
                "type": "object"
              },
              {
                "type": "null"
              }
            ],
            "title": "Model Scores",
            "description": "DIAGNOSTIC (only present when model=cnn|encoder): per-classifier {ran, max_prob, threshold, (encoder) skipped_regions}. A single-model verdict is NOT the production verdict — see models_run."
          },
          "forensic_report_url": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Forensic Report Url",
            "description": "Only when forensic_report=true AND status=FAIL: a short-lived SIGNED URL to the HTML forensic work-up. Fetch it within FORENSIC_URL_TTL_SEC (~5 min) or it is deleted. Self-authorizing (opens in a browser without the API key)."
          },
          "forensic_report_expires_at": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Forensic Report Expires At",
            "description": "ISO-8601 UTC expiry of forensic_report_url; the report folder is deleted at/after this time."
          }
        },
        "type": "object",
        "required": [
          "status",
          "total_chunks",
          "chunks_scanned",
          "malicious_chunks",
          "max_confidence",
          "detections",
          "model_version"
        ],
        "title": "ScanResponse",
        "description": "Response model for scan results."
      },
      "Error": {
        "type": "object",
        "title": "Error",
        "properties": {
          "error": {
            "type": "string",
            "description": "Human-readable explanation. Always present."
          },
          "code": {
            "type": "string",
            "description": "Stable machine-readable code. Absent on some errors; branch on the HTTP status first.",
            "enum": [
              "insufficient_credits",
              "feature_not_available",
              "payload_too_large",
              "rate_limited",
              "scan_failed",
              "scan_unavailable",
              "scan_timeout"
            ]
          },
          "reference": {
            "type": "string",
            "description": "On a 5xx, quote this to support; it locates the failure in our logs."
          }
        },
        "required": [
          "error"
        ]
      }
    }
  }
}