{
  "openapi": "3.1.0",
  "info": {
    "title": "AnonMail v1 API",
    "version": "1.0.0",
    "description": "Public Bearer-key REST API for AnonMail, an anonymous whistleblowing / case-management platform.\n\nThis specification documents the **v1 REST API**, which is the supported contract for external integrations (embeddable widgets, partner back-office systems, automation). All endpoints are served under `/api/v1`.\n\n**Authentication.** Keyed endpoints require an API key passed as `Authorization: Bearer ak_<40 hex>`. Keys carry scopes: `read` (required for all `GET` endpoints) and `write` (required for `POST`/`PATCH` mutations). The tenant is resolved from the request subdomain; the platform injects an `X-Tenant-ID` header internally. Two endpoints are intentionally public (no key): `POST /api/v1/submit` and `GET /api/v1/form-config`.\n\n**Rate limiting.** Keyed endpoints return `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (unix seconds) on every response. When the limit is exceeded the API returns `429` with a `Retry-After` header (seconds). `POST /api/v1/submit` is rate-limited per submitter fingerprint rather than per key.\n\n**Envelopes.** Success responses are `{ \"success\": true, ... }`. Errors are `{ \"success\": false, \"error\": \"<message>\" }`. The legal-hold `423` response on case mutations additionally carries a `reason` field.\n\n**Do not confuse the two submit endpoints.** This v1 API's `POST /api/v1/submit` is the widget endpoint: it does NOT take `agree_policy`, and it returns the tracking code NESTED under `data` (`{ \"data\": { \"tracking_code\", \"case_id\" } }`). It is SEPARATE from the cookie-session admin endpoints (e.g. `/api/cases`, `/api/dashboard/*`) and from the legacy flat `POST /api/submit`, which DOES require `agree_policy: true` and returns a FLAT body (`{ \"tracking_code\", \"message\" }`). Those internal/admin endpoints are NOT part of this v1 contract and are out of scope for external integrators. Use `/api/v1/submit` for integrations."
  },
  "servers": [
    {
      "url": "https://vetmail.vercel.app",
      "description": "Production deployment (single-tenant mode; the tenant is resolved server-side)."
    }
  ],
  "tags": [
    {
      "name": "Public",
      "description": "Endpoints that require no API key."
    },
    {
      "name": "Cases",
      "description": "Case listing, detail, status updates and replies (keyed)."
    },
    {
      "name": "Analytics",
      "description": "Aggregate statistics and audit logs (keyed)."
    },
    {
      "name": "Webhooks",
      "description": "REST Hooks — self-manage webhook subscriptions via API key."
    }
  ],
  "security": [
    {
      "bearerApiKey": []
    }
  ],
  "paths": {
    "/api/v1/submit": {
      "post": {
        "tags": [
          "Public",
          "Cases"
        ],
        "summary": "Submit a case (public widget endpoint)",
        "description": "Public, no-API-key submission used by the embeddable widget. Rate-limited per submitter fingerprint (User-Agent + tenant), NOT per API key. Does NOT accept `agree_policy`. Returns the anonymous tracking code NESTED under `data` together with the new `case_id`. This is distinct from the legacy flat `POST /api/submit` (which requires `agree_policy` and returns a flat `{ tracking_code, message }`).",
        "operationId": "submitCase",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SubmitRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Case created. Returns the tracking code (shown to the reporter only once) and case id.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SubmitResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid request body or field validation failure.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded, or the tenant's monthly case limit has been reached.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "500": {
            "description": "Internal server error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/cases": {
      "get": {
        "tags": [
          "Cases"
        ],
        "summary": "List cases",
        "description": "Returns a paginated, filterable list of cases for the authenticated tenant. Requires the `read` scope.",
        "operationId": "listCases",
        "security": [
          {
            "bearerApiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/StatusFilter"
          },
          {
            "$ref": "#/components/parameters/PriorityFilter"
          },
          {
            "$ref": "#/components/parameters/CategoryFilter"
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "description": "Case-insensitive substring match against title and department. Searching by tracking code is not supported.",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/Page"
          },
          {
            "$ref": "#/components/parameters/PerPage"
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of cases.",
            "headers": {
              "$ref": "#/components/headers/RateLimitHeaders"
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CaseListResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/api/v1/cases/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CaseId"
        }
      ],
      "get": {
        "tags": [
          "Cases"
        ],
        "summary": "Get case detail",
        "description": "Returns full detail for a single case, including non-internal messages. Internal notes (`is_internal = true`) are excluded. Requires the `read` scope.",
        "operationId": "getCase",
        "security": [
          {
            "bearerApiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "Case detail.",
            "headers": {
              "$ref": "#/components/headers/RateLimitHeaders"
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CaseDetailResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadCaseId"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/CaseNotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "patch": {
        "tags": [
          "Cases"
        ],
        "summary": "Update a case",
        "description": "Updates a case's `status` and/or `assigned_to`. Status changes must follow the allowed state machine; an invalid transition returns `400` with a message of the form `Cannot transition from \"X\" to \"Y\"`. Sending no updatable fields returns `400` (`No fields to update`). Cases under legal hold return `423`. Requires the `write` scope.",
        "operationId": "updateCase",
        "security": [
          {
            "bearerApiKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateCaseRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Case updated.",
            "headers": {
              "$ref": "#/components/headers/RateLimitHeaders"
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CaseUpdateResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid body, invalid case id, invalid status transition, or no fields to update.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/CaseNotFound"
          },
          "423": {
            "$ref": "#/components/responses/LegalHold"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/api/v1/cases/{id}/reply": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CaseId"
        }
      ],
      "post": {
        "tags": [
          "Cases"
        ],
        "summary": "Reply to a case",
        "description": "Adds a reply to a case as the HR/handler side. Replies sent via the API are always non-internal (visible to the reporter). Cases under legal hold return `423`. Requires the `write` scope.",
        "operationId": "replyToCase",
        "security": [
          {
            "bearerApiKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ReplyRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Reply created.",
            "headers": {
              "$ref": "#/components/headers/RateLimitHeaders"
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ReplyResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid body or invalid case id.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/CaseNotFound"
          },
          "423": {
            "$ref": "#/components/responses/LegalHold"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/api/v1/stats": {
      "get": {
        "tags": [
          "Analytics"
        ],
        "summary": "Get case statistics",
        "description": "Returns aggregate case counts grouped by status, category and priority, plus average response/resolution times in hours (null when not computable). Requires the `read` scope.",
        "operationId": "getStats",
        "security": [
          {
            "bearerApiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "Aggregate statistics.",
            "headers": {
              "$ref": "#/components/headers/RateLimitHeaders"
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StatsResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/api/v1/audit-logs": {
      "get": {
        "tags": [
          "Analytics"
        ],
        "summary": "Query audit logs",
        "description": "Returns a paginated, filterable list of audit-log entries for the tenant. Requires the `read` scope.",
        "operationId": "listAuditLogs",
        "security": [
          {
            "bearerApiKey": []
          }
        ],
        "parameters": [
          {
            "name": "action",
            "in": "query",
            "required": false,
            "description": "Filter by exact audit action (e.g. case.status_changed).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "Lower bound (inclusive) on created_at, ISO 8601 timestamp.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "Upper bound (inclusive) on created_at, ISO 8601 timestamp.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "$ref": "#/components/parameters/Page"
          },
          {
            "$ref": "#/components/parameters/PerPage"
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of audit-log entries.",
            "headers": {
              "$ref": "#/components/headers/RateLimitHeaders"
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuditLogListResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/api/v1/form-config": {
      "get": {
        "tags": [
          "Public"
        ],
        "summary": "Get form configuration (public)",
        "description": "Public, no-API-key endpoint returning the tenant's custom form fields, used to render the widget/submission form. Optionally filtered by category (category-specific fields plus global fields with no category).",
        "operationId": "getFormConfig",
        "security": [],
        "parameters": [
          {
            "name": "category",
            "in": "query",
            "required": false,
            "description": "When provided, returns fields for that category plus global (category-less) fields.",
            "schema": {
              "$ref": "#/components/schemas/Category"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Form configuration for the tenant.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FormConfigResponse"
                }
              }
            }
          },
          "500": {
            "description": "Internal server error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/webhooks": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "summary": "List webhook subscriptions",
        "description": "Lists this tenant's webhook subscriptions. Requires the `read` scope. The signing secret is never returned here.",
        "operationId": "listWebhooks",
        "security": [
          {
            "bearerApiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "Subscriptions.",
            "headers": {
              "$ref": "#/components/headers/RateLimitHeaders"
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Webhook"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing/invalid API key."
          },
          "403": {
            "description": "Missing read scope."
          },
          "429": {
            "description": "Rate limit exceeded."
          },
          "500": {
            "description": "Server error."
          }
        }
      },
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Subscribe to events (REST Hook)",
        "description": "Registers a webhook subscription. Requires the `write` scope. The URL must be https and public (SSRF-guarded). Returns the HMAC signing `secret` exactly once. Slack (`hooks.slack.com`) and Teams (`*.webhook.office.com`) URLs are auto-formatted.",
        "operationId": "subscribeWebhook",
        "security": [
          {
            "bearerApiKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookSubscribeRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created (secret returned once).",
            "headers": {
              "$ref": "#/components/headers/RateLimitHeaders"
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "data": {
                      "$ref": "#/components/schemas/WebhookWithSecret"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid body or URL rejected (SSRF)."
          },
          "401": {
            "description": "Missing/invalid API key."
          },
          "403": {
            "description": "Missing write scope."
          },
          "429": {
            "description": "Rate limit exceeded."
          },
          "500": {
            "description": "Server error."
          }
        }
      }
    },
    "/api/v1/webhooks/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          },
          "description": "Webhook subscription id."
        }
      ],
      "delete": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Unsubscribe",
        "description": "Deletes a webhook subscription. Requires the `write` scope.",
        "operationId": "unsubscribeWebhook",
        "security": [
          {
            "bearerApiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted.",
            "headers": {
              "$ref": "#/components/headers/RateLimitHeaders"
            }
          },
          "400": {
            "description": "Invalid id."
          },
          "401": {
            "description": "Missing/invalid API key."
          },
          "403": {
            "description": "Missing write scope."
          },
          "404": {
            "description": "Not found."
          },
          "429": {
            "description": "Rate limit exceeded."
          },
          "500": {
            "description": "Server error."
          }
        }
      }
    },
    "/api/v1/configuration": {
      "get": {
        "tags": [
          "Public"
        ],
        "summary": "Integration capability document",
        "description": "Public, unauthenticated capability document: API base, OpenAPI URL, supported webhook events, scopes, auth, features. Also served at `/.well-known/anonmail-configuration`.",
        "operationId": "getConfiguration",
        "security": [],
        "responses": {
          "200": {
            "description": "Capabilities."
          }
        }
      }
    },
    "/api/v1/openapi.json": {
      "get": {
        "tags": [
          "Public"
        ],
        "summary": "OpenAPI document",
        "description": "Serves this OpenAPI 3.1 document (mirror of `/openapi.json`).",
        "operationId": "getOpenApi",
        "security": [],
        "responses": {
          "200": {
            "description": "OpenAPI JSON."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerApiKey": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "ak_<hex>",
        "description": "API key sent as `Authorization: Bearer ak_<40 hex>`. Keys carry `read` and/or `write` scopes. `read` is required for all GET endpoints; `write` is required for POST/PATCH mutations."
      }
    },
    "parameters": {
      "CaseId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "Case UUID.",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "Page": {
        "name": "page",
        "in": "query",
        "required": false,
        "description": "1-based page number.",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "default": 1
        }
      },
      "PerPage": {
        "name": "per_page",
        "in": "query",
        "required": false,
        "description": "Items per page (max 100).",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 100,
          "default": 20
        }
      },
      "StatusFilter": {
        "name": "status",
        "in": "query",
        "required": false,
        "description": "Filter by case status.",
        "schema": {
          "$ref": "#/components/schemas/Status"
        }
      },
      "PriorityFilter": {
        "name": "priority",
        "in": "query",
        "required": false,
        "description": "Filter by case priority.",
        "schema": {
          "$ref": "#/components/schemas/Priority"
        }
      },
      "CategoryFilter": {
        "name": "category",
        "in": "query",
        "required": false,
        "description": "Filter by case category.",
        "schema": {
          "$ref": "#/components/schemas/Category"
        }
      }
    },
    "headers": {
      "RateLimitHeaders": {
        "X-RateLimit-Limit": {
          "description": "Maximum requests allowed in the current window.",
          "schema": {
            "type": "integer"
          }
        },
        "X-RateLimit-Remaining": {
          "description": "Requests remaining in the current window.",
          "schema": {
            "type": "integer"
          }
        },
        "X-RateLimit-Reset": {
          "description": "Unix timestamp (seconds) when the window resets.",
          "schema": {
            "type": "integer"
          }
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Missing or invalid API key.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "Forbidden": {
        "description": "API key lacks the required scope for this operation.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "BadCaseId": {
        "description": "Invalid case ID format (must be a UUID).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "CaseNotFound": {
        "description": "No case with the given id exists for this tenant.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "LegalHold": {
        "description": "The case is under legal hold and cannot be mutated. The error envelope additionally carries a `reason`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/LegalHoldError"
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "Rate limit exceeded for the API key.",
        "headers": {
          "Retry-After": {
            "description": "Seconds to wait before retrying.",
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "InternalError": {
        "description": "Internal server error.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      }
    },
    "schemas": {
      "Category": {
        "type": "string",
        "description": "Case category.",
        "enum": [
          "safety",
          "harassment",
          "discrimination",
          "ethics",
          "labor",
          "management",
          "environment",
          "other"
        ]
      },
      "Priority": {
        "type": "string",
        "description": "Case priority.",
        "enum": [
          "low",
          "medium",
          "high",
          "urgent"
        ]
      },
      "Status": {
        "type": "string",
        "description": "Case status.",
        "enum": [
          "submitted",
          "acknowledged",
          "investigating",
          "pending_decision",
          "resolved",
          "escalated",
          "closed"
        ]
      },
      "AuthorType": {
        "type": "string",
        "description": "Author of a case message: the reporting employee or the HR/handler side.",
        "enum": [
          "employee",
          "hr"
        ]
      },
      "ErrorResponse": {
        "type": "object",
        "description": "Standard error envelope.",
        "required": [
          "success",
          "error"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "const": false
          },
          "error": {
            "type": "string",
            "description": "Human-readable error message."
          }
        }
      },
      "LegalHoldError": {
        "type": "object",
        "description": "Error envelope for legal-hold (423) responses; includes a `reason`.",
        "required": [
          "success",
          "error",
          "reason"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "const": false
          },
          "error": {
            "type": "string",
            "examples": [
              "Case is under legal hold"
            ]
          },
          "reason": {
            "type": "string",
            "description": "Reason the case is under legal hold."
          }
        }
      },
      "Pagination": {
        "type": "object",
        "description": "Pagination metadata.",
        "required": [
          "total",
          "page",
          "per_page"
        ],
        "properties": {
          "total": {
            "type": "integer",
            "description": "Total matching records."
          },
          "page": {
            "type": "integer",
            "description": "Current 1-based page."
          },
          "per_page": {
            "type": "integer",
            "description": "Page size."
          }
        }
      },
      "SubmitRequest": {
        "type": "object",
        "description": "Public widget submission. Note: there is NO `agree_policy` field on this v1 endpoint.",
        "required": [
          "category",
          "priority",
          "title",
          "content"
        ],
        "properties": {
          "category": {
            "$ref": "#/components/schemas/Category"
          },
          "priority": {
            "$ref": "#/components/schemas/Priority"
          },
          "title": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100,
            "description": "Case title."
          },
          "content": {
            "type": "string",
            "minLength": 1,
            "maxLength": 10000,
            "description": "Case body / details."
          },
          "department": {
            "type": "string",
            "maxLength": 50,
            "description": "Optional department name."
          }
        }
      },
      "SubmitResponse": {
        "type": "object",
        "required": [
          "success",
          "data"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "const": true
          },
          "data": {
            "type": "object",
            "required": [
              "tracking_code",
              "case_id"
            ],
            "properties": {
              "tracking_code": {
                "type": "string",
                "description": "Anonymous tracking code, shown to the reporter only once.",
                "examples": [
                  "TRK-A3B7-KN2Q-WE"
                ]
              },
              "case_id": {
                "type": "string",
                "format": "uuid",
                "description": "Internal case id."
              }
            }
          }
        }
      },
      "UpdateCaseRequest": {
        "type": "object",
        "description": "Fields to update on a case. At least one must be provided.",
        "minProperties": 1,
        "properties": {
          "status": {
            "$ref": "#/components/schemas/Status"
          },
          "assigned_to": {
            "type": "string",
            "maxLength": 100,
            "description": "Handler/assignee name."
          }
        }
      },
      "ReplyRequest": {
        "type": "object",
        "required": [
          "content"
        ],
        "properties": {
          "content": {
            "type": "string",
            "minLength": 1,
            "maxLength": 10000,
            "description": "Reply body. Always sent as a non-internal (reporter-visible) message."
          }
        }
      },
      "CaseMessage": {
        "type": "object",
        "description": "A non-internal case message.",
        "required": [
          "id",
          "author_type",
          "content",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "author_type": {
            "$ref": "#/components/schemas/AuthorType"
          },
          "content": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "CaseSummary": {
        "type": "object",
        "description": "Case fields returned in list views.",
        "required": [
          "id",
          "category",
          "priority",
          "status",
          "title",
          "submitted_at",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "category": {
            "$ref": "#/components/schemas/Category"
          },
          "priority": {
            "$ref": "#/components/schemas/Priority"
          },
          "status": {
            "$ref": "#/components/schemas/Status"
          },
          "title": {
            "type": "string"
          },
          "department": {
            "type": [
              "string",
              "null"
            ]
          },
          "assigned_to": {
            "type": [
              "string",
              "null"
            ]
          },
          "submitted_at": {
            "type": "string",
            "format": "date-time"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "ai_risk_score": {
            "type": [
              "integer",
              "null"
            ],
            "description": "AI-assigned risk score, or null if not analyzed."
          },
          "ai_risk_level": {
            "type": [
              "string",
              "null"
            ],
            "description": "AI-assigned risk level, or null if not analyzed."
          }
        }
      },
      "CaseDetail": {
        "type": "object",
        "description": "Full case detail. Internal notes are excluded from `messages`.",
        "required": [
          "id",
          "category",
          "priority",
          "status",
          "title",
          "content",
          "submitted_at",
          "created_at",
          "updated_at",
          "case_ref",
          "messages"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "category": {
            "$ref": "#/components/schemas/Category"
          },
          "priority": {
            "$ref": "#/components/schemas/Priority"
          },
          "status": {
            "$ref": "#/components/schemas/Status"
          },
          "title": {
            "type": "string"
          },
          "content": {
            "type": "string"
          },
          "department": {
            "type": [
              "string",
              "null"
            ]
          },
          "tags": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            }
          },
          "assigned_to": {
            "type": [
              "string",
              "null"
            ]
          },
          "sla_deadline": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "submitted_at": {
            "type": "string",
            "format": "date-time"
          },
          "acknowledged_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "resolved_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "closed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "ai_risk_score": {
            "type": [
              "integer",
              "null"
            ]
          },
          "ai_risk_level": {
            "type": [
              "string",
              "null"
            ]
          },
          "ai_summary": {
            "type": [
              "string",
              "null"
            ]
          },
          "case_ref": {
            "type": "string",
            "description": "Human-readable case reference derived from the case id."
          },
          "messages": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CaseMessage"
            }
          }
        }
      },
      "CaseUpdateResult": {
        "type": "object",
        "description": "Case fields returned after a PATCH update.",
        "required": [
          "id",
          "category",
          "priority",
          "status",
          "title",
          "submitted_at",
          "updated_at",
          "case_ref"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "category": {
            "$ref": "#/components/schemas/Category"
          },
          "priority": {
            "$ref": "#/components/schemas/Priority"
          },
          "status": {
            "$ref": "#/components/schemas/Status"
          },
          "title": {
            "type": "string"
          },
          "department": {
            "type": [
              "string",
              "null"
            ]
          },
          "assigned_to": {
            "type": [
              "string",
              "null"
            ]
          },
          "submitted_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "case_ref": {
            "type": "string"
          }
        }
      },
      "AuditLog": {
        "type": "object",
        "description": "An audit-log entry.",
        "required": [
          "id",
          "action",
          "actor",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "action": {
            "type": "string",
            "description": "Audit action identifier."
          },
          "actor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Who performed the action."
          },
          "target": {
            "type": [
              "string",
              "null"
            ],
            "description": "What the action was performed on."
          },
          "details": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true,
            "description": "Action-specific details object."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "FormField": {
        "type": "object",
        "description": "A custom form field definition.",
        "required": [
          "field_key",
          "field_label",
          "field_type",
          "is_required",
          "sort_order"
        ],
        "properties": {
          "field_key": {
            "type": "string"
          },
          "field_label": {
            "type": "string"
          },
          "field_type": {
            "type": "string",
            "description": "Input type for the field (e.g. text, select)."
          },
          "options": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            },
            "description": "Selectable options, or null for free-form fields."
          },
          "is_required": {
            "type": "boolean"
          },
          "sort_order": {
            "type": "integer"
          },
          "category": {
            "type": [
              "string",
              "null"
            ],
            "description": "Category this field applies to, or null for a global field."
          }
        }
      },
      "Stats": {
        "type": "object",
        "description": "Aggregate case statistics.",
        "required": [
          "total",
          "by_status",
          "by_category",
          "by_priority",
          "avg_response_hours",
          "avg_resolution_hours"
        ],
        "properties": {
          "total": {
            "type": "integer"
          },
          "by_status": {
            "type": "object",
            "description": "Counts keyed by status. Only statuses with at least one case appear.",
            "additionalProperties": {
              "type": "integer"
            }
          },
          "by_category": {
            "type": "object",
            "description": "Counts keyed by category. Only categories with at least one case appear.",
            "additionalProperties": {
              "type": "integer"
            }
          },
          "by_priority": {
            "type": "object",
            "description": "Counts keyed by priority. Only priorities with at least one case appear.",
            "additionalProperties": {
              "type": "integer"
            }
          },
          "avg_response_hours": {
            "type": [
              "number",
              "null"
            ],
            "description": "Average hours from submission to acknowledgement, or null."
          },
          "avg_resolution_hours": {
            "type": [
              "number",
              "null"
            ],
            "description": "Average hours from submission to resolution, or null."
          }
        }
      },
      "CaseListResponse": {
        "type": "object",
        "required": [
          "success",
          "data",
          "pagination"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "const": true
          },
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CaseSummary"
            }
          },
          "pagination": {
            "$ref": "#/components/schemas/Pagination"
          }
        }
      },
      "CaseDetailResponse": {
        "type": "object",
        "required": [
          "success",
          "data"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "const": true
          },
          "data": {
            "$ref": "#/components/schemas/CaseDetail"
          }
        }
      },
      "CaseUpdateResponse": {
        "type": "object",
        "required": [
          "success",
          "data"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "const": true
          },
          "data": {
            "$ref": "#/components/schemas/CaseUpdateResult"
          }
        }
      },
      "ReplyResponse": {
        "type": "object",
        "required": [
          "success",
          "data"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "const": true
          },
          "data": {
            "$ref": "#/components/schemas/CaseMessage"
          }
        }
      },
      "StatsResponse": {
        "type": "object",
        "required": [
          "success",
          "data"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "const": true
          },
          "data": {
            "$ref": "#/components/schemas/Stats"
          }
        }
      },
      "AuditLogListResponse": {
        "type": "object",
        "required": [
          "success",
          "data",
          "pagination"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "const": true
          },
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AuditLog"
            }
          },
          "pagination": {
            "$ref": "#/components/schemas/Pagination"
          }
        }
      },
      "FormConfig": {
        "type": "object",
        "required": [
          "tenant_id",
          "fields"
        ],
        "properties": {
          "tenant_id": {
            "type": "string",
            "description": "Tenant identifier."
          },
          "fields": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FormField"
            }
          }
        }
      },
      "FormConfigResponse": {
        "type": "object",
        "required": [
          "success",
          "data"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "const": true
          },
          "data": {
            "$ref": "#/components/schemas/FormConfig"
          }
        }
      },
      "Webhook": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "url": {
            "type": "string",
            "format": "uri"
          },
          "events": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "is_active": {
            "type": "boolean"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "WebhookWithSecret": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Webhook"
          },
          {
            "type": "object",
            "properties": {
              "secret": {
                "type": "string",
                "description": "HMAC signing secret — shown once."
              }
            }
          }
        ]
      },
      "WebhookSubscribeRequest": {
        "type": "object",
        "required": [
          "url",
          "events"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "description": "https URL (public)."
          },
          "events": {
            "type": "array",
            "minItems": 1,
            "items": {
              "type": "string",
              "enum": [
                "case.created",
                "case.updated",
                "case.status_changed",
                "case.replied",
                "case.assigned",
                "case.resolved",
                "case.closed",
                "sla.breached",
                "retaliation.reported",
                "case.legal_hold_changed"
              ]
            }
          },
          "description": {
            "type": "string",
            "maxLength": 200
          }
        }
      }
    }
  }
}
