> ## Documentation Index
> Fetch the complete documentation index at: https://docs.qwedai.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# API endpoints

> Complete QWED API endpoint reference covering health checks, verification, batch operations, agent endpoints, observability, and admin routes.

## Base URL

QWED has no hosted API. Use the address of the QWED server you run; locally it is:

```text theme={null}
http://localhost:8000
```

## Health check

### GET /health

Check API status. No authentication required.

```bash theme={null}
curl http://localhost:8000/health
```

**Response:**

```json theme={null}
{
  "status": "healthy",
  "service": "QWED Platform",
  "version": "5.1.0",
  "timestamp": "2024-12-20T12:00:00Z"
}
```

### GET /health/identity

Challenge-response proof that the server holds a given secret set. `qwed init` calls this endpoint before bootstrapping an API key to confirm it's talking to the right local server. See [`qwed init`](https://docs.qwedai.tech/advanced/cli#qwed-init-onboarding-and-bootstrap). No authentication required.

**Query parameters:**

| Parameter | Type | Required | Description |
| - | - | - | - |
| `nonce` | string | Yes | Caller-generated random value, 1–128 characters. Use a fresh nonce for every request. |

```bash theme={null}
# Fresh random value per request, e.g. Python's secrets.token_urlsafe(24)
curl http://localhost:8000/health/identity?nonce=Vh8Qm2xT-0pLw6KdYs3rZbNf_5JtAe1X
```

**Response:**

```json theme={null}
{
  "identity": {
    "jwt_hmac": "9f2c...e41a",
    "lookup_hmac": "b07d...5c13",
    "audit_hmac": "4e8a...d270"
  }
}
```

| Field | Description |
| - | - |
| `identity.jwt_hmac` | Hex HMAC-SHA256 under `QWED_JWT_SECRET_KEY` of the message `qwed-server-identity-v1:jwt:<nonce>` |
| `identity.lookup_hmac` | Hex HMAC-SHA256 under `QWED_API_KEY_LOOKUP_SECRET` of the message `qwed-server-identity-v1:lookup:<nonce>` |
| `identity.audit_hmac` | Hex HMAC-SHA256 under `QWED_AUDIT_SECRET_KEY` of the message `qwed-server-identity-v1:audit:<nonce>` |

To verify, compute the same HMACs with your local secrets and compare them in constant time. The secrets themselves are never returned.

**Error responses:**

| Status | Detail | Cause |
| - | - | - |
| `400` | `nonce is required (max 128 characters)` | `nonce` is missing, empty, or longer than 128 characters |
| `503` | `server secrets not configured` | The server has no `QWED_JWT_SECRET_KEY`, `QWED_API_KEY_LOOKUP_SECRET`, or `QWED_AUDIT_SECRET_KEY` set |

Servers released before this endpoint existed return `404`.

***

## Request limits

Every `/verify` route enforces a request body cap. QWED rejects an oversized body before the handler reads or parses it.

| Route | Body cap |
| - | - |
| JSON routes: `/verify/natural_language`, `/verify/logic`, `/verify/fact`, `/verify/code`, `/verify/math`, `/verify/sql`, `/verify/rag`, `/verify/process`, `/verify/consensus`, `/verify/batch` | 1,000,000 bytes |
| `/verify/stats` (multipart upload) | 10,000,000-byte file plus 64 KiB envelope headroom |
| `/verify/image` (multipart upload) | 10 MiB file plus 64 KiB envelope headroom |

The server also bounds slow work so one request can't stall the API for other callers. Self-hosters can tune these bounds with environment variables. See [Availability limits](https://docs.qwedai.tech/advanced/self-hosting.md#availability-limits).

| Status | Detail | Cause |
| - | - | - |
| `408` | `Request body not received in time.` | The client didn't finish sending the body within the read deadline (10 seconds for JSON routes, 30 seconds for uploads) |
| `413` | `Request body exceeds the <N> byte limit for this endpoint.` | The body is larger than the route's cap |
| `503` | `Server busy — retry shortly.` | Too many JSON requests are in flight on this server process |
| `503` | `Upload capacity reached — retry shortly.` | Too many `/verify/stats` or `/verify/image` uploads are in flight on this server process |

Treat `503` responses from these limits as retryable. Back off and resend the request.

***

## Verification endpoints

### POST /verify/natural\_language

Main entry point for verifying natural language queries. Routes through the QWED Control Plane with multi-tenancy support.

**Request:**

```json theme={null}
{
  "query": "What is 15% of 200?",
  "provider": "openai"
}
```

| Parameter | Type | Required | Description |
| - | - | - | - |
| `query` | string | Yes | Natural language claim to verify |
| `provider` | string | No | Preferred LLM provider (e.g., `openai`, `anthropic`) |

**Response:**

The endpoint returns the [`DiagnosticResult`](https://docs.qwedai.tech/advanced/diagnostics) verdict that the control plane enforced for the translated expression, flattened with its developer fields, plus a separate `admission` decision. QWED no longer re-derives the verdict from the legacy engine dictionary, which turned every decided claim into `UNVERIFIABLE`.

```json theme={null}
{
  "status": "VERIFIED",
  "agent_message": "Expression verified successfully",
  "proof_ref": "sha256:...",
  "is_authoritative": true,
  "is_valid": true,
  "calculated_value": 30.0,
  "claimed_value": 30.0,
  "translated_expression": "0.15 * 200",
  "verification_scope": "translated_expression_only",
  "query_semantics_verified": false,
  "admission": "BLOCKED",
  "developer_fields": { "...": "same fields as above" }
}
```

| Field | Type | Description |
| - | - | - |
| `status` | string | Verdict for the translated expression: `VERIFIED`, `UNVERIFIABLE`, or `BLOCKED` |
| `is_valid` | boolean | `true` when the translated claim holds. A translated claim proven false is `VERIFIED` with `is_valid: false` |
| `translated_expression` | string | The expression the LLM produced and the engine evaluated |
| `verification_scope` | string | Always `"translated_expression_only"`. The verdict covers the translated expression, not your question |
| `query_semantics_verified` | boolean | Always `false`. QWED cannot prove the translation means what you asked |
| `admission` | string | Always `"BLOCKED"` (`DENY`) for this endpoint, even when the translated claim holds |

Admission is always `BLOCKED` because the LLM translation itself is unverified, so QWED never admits the result as an answer to your question. The verdict itself is unchanged. The audit log row's `is_verified` follows admission, so it is always `false`. The row's domain is `NATURAL_LANGUAGE`. Rows written before this fix store `MATH`, because the domain is part of each signed, chained entry and QWED doesn't rewrite it. QWED returns `BLOCKED` as the verdict when the control plane returns a malformed diagnostic or when the output sanitizer rejects the translated expression.

For a deterministic, admissible result, send the expression to [`POST /verify/math`](#post-%2Fverify%2Fmath) directly.

The control plane also builds a `trust_boundary` record that describes what the pipeline proved. The control-plane response carries the enforced verdict as `diagnostic` together with `admission`.

**`trust_boundary` fields:**

| Field | Type | Description |
| - | - | - |
| `query_interpretation_source` | string | Always `"llm_translation"` |
| `query_semantics_verified` | boolean | Always `false` — QWED cannot verify that the LLM correctly interpreted the user's intent |
| `verification_scope` | string | Always `"translated_expression_only"` — the attestation and enforcement below bind to the translated expression, not the natural-language `user_query` |
| `deterministic_expression_evaluation` | boolean | `true` when the inner engine status was `VERIFIED` or `DISPROVEN` |
| `formal_proof` | boolean | Always `false` |
| `translation_claim_self_consistent` | boolean | Whether the translated expression matched its own claimed answer |
| `provider_used` | string | LLM provider used for translation |
| `trust_enforced` | string | Status returned by the mandatory trust-boundary enforcement step. Matches `overall_status`. |
| `attestation_policy` | string | Always `"mandatory"` — attestation admission is always required, never advisory |
| `attestation_error` | string | Present only when the underlying attestation failed to sign. Machine-readable code (e.g. `SIGNING_FAILURE`, `CRYPTO_UNAVAILABLE`) explaining why the response was downgraded to `BLOCKED`. |
| `overall_status` | string | Final status set from the enforced decision — never from the raw verifier verdict. Mirrors the control-plane response `status`. |

#### Mandatory attestation admission

The control plane treats attestation as an admission gate, not a decoration:

* The final `status` and `trust_boundary.overall_status` are set by `enforce_trust_decision(..., require_attestation=True)` after signing, not by the raw verifier verdict. A `VERIFIED` math result is only surfaced once `create_verification_attestation()` returns `ISSUED`.
* A translated claim proven false is signed and attested like a true one, with `verified: false` in the attestation and `is_valid: false` in the verdict.
* If attestation signing fails for any reason, the response is downgraded to `BLOCKED` (fail-closed). The attestation error code is echoed under `trust_boundary.attestation_error` and the top-level response omits the attestation token.
* The attestation `qwed.query_hash` binds to the translated expression that the deterministic engine actually evaluated — not to the natural-language `user_query`. This matches the disclosed `verification_scope: "translated_expression_only"`: the natural-language query still appears in `response.user_query` for display, but downstream consumers verifying the attestation should hash the translated expression, not the user prose.

See [Cryptographic attestations](https://docs.qwedai.tech/advanced/attestations.md) for the full `AttestationResult` contract and [Trust boundary](https://docs.qwedai.tech/engines/math.md#trust-boundary) for the math-engine discussion.

> [!NOTE]
>
> The LLM provider call runs off the server's event loop under `QWED_PROVIDER_TIMEOUT` (default 30 seconds). If the provider doesn't respond in time, the request fails closed with an `ERROR` status instead of stalling other requests.

***

### POST /verify/math

> [!NOTE]
>
> Updated in v5.1.0: `verify_identity()` numerical sampling fallback now returns `BLOCKED` with `is_equivalent: false` instead of `UNKNOWN`. This fail-closed behavior ensures that sampling-only agreement is never mistaken for a verified identity.

Verify mathematical expressions or equations using SymPy symbolic computation.

**Request:**

```json theme={null}
{
  "expression": "x**2 + 2*x + 1 = (x+1)**2",
  "context": {
    "domain": "real"
  }
}
```

| Parameter | Type | Required | Description |
| - | - | - | - |
| `expression` | string | Yes | Mathematical expression or equation. Use `=` for equations |
| `context` | object | No | Optional context (e.g., `{"domain": "real"}` to restrict to real numbers) |

**Response (equation):**

```json theme={null}
{
  "status": "VERIFIED",
  "is_valid": true,
  "result": true,
  "left_side": "x**2 + 2*x + 1",
  "right_side": "(x + 1)**2",
  "simplified_difference": "0",
  "agent_message": "Identity is true",
  "proof_ref": "sha256:...",
  "attestation": "eyJ...",
  "admission": "ADMIT"
}
```

Every response from this endpoint includes `admission`. Gate on `admission`, not on `status`: `ADMIT` only when `status` is `VERIFIED` and `is_valid` is `true`. A `VERIFIED` response also carries `proof_ref` and its signed `attestation`.

| Outcome | Example | `status` | `is_valid` | `admission` |
| - | - | - | - | - |
| Proven true | `2 + 2 = 4` | `VERIFIED` | `true` | `ADMIT` |
| Proven false | `2 + 2 = 5` | `VERIFIED` | `false` | `BLOCKED` |
| Undecided symbolic difference | SymPy can't decide the difference is zero or non-zero | `UNVERIFIABLE` | `false` | `BLOCKED` |
| Domain-restricted identity | `x/x = 1`, `(x**2-1)/(x-1) = x+1` | `UNVERIFIABLE` | `false` | `BLOCKED` |
| Undefined part | `log(0)**0 = 2` | `UNVERIFIABLE` | `false` | `BLOCKED` |
| Parse error | `d/dx(x^2) = 2x`, unparseable text | `BLOCKED` | `false` | `BLOCKED` |

The expression can use `diff(f, x)` or `Derivative(f, x)`, for example `diff(x**2, x) == 2*x`. `==` reads as `=`. See [Derivatives in `/verify/math` identities](https://docs.qwedai.tech/engines/math#derivatives-in-%2Fverify%2Fmath-identities).

**Response (proven false):**

```json theme={null}
{
  "status": "VERIFIED",
  "is_valid": false,
  "result": false,
  "left_side": "4",
  "right_side": "5",
  "simplified_difference": "-1",
  "agent_message": "Identity is false",
  "proof_ref": "sha256:...",
  "attestation": "eyJ...",
  "admission": "BLOCKED"
}
```

A proven-false identity is a decided verdict, so `status` is `VERIFIED` and `proof_ref` is set. Previous versions returned `UNVERIFIABLE` for it.

**Response (domain-restricted identity):** An identity that holds only after SymPy cancels a removable singularity returns `UNVERIFIABLE` with `constraint_id: "math.domain_restricted_identity"`. For example, `x/x = 1` is false at `x = 0`. See [Domain-restricted identities](https://docs.qwedai.tech/engines/math.md#domain-restricted-identities).

**Response (undefined part):** A claim with a side that SymPy evaluates away from an undefined value (`zoo` or `nan`) is not disproven. For example, SymPy reads `log(0)**0` as `1`. `log(0)**0 = 2` returns `UNVERIFIABLE` with `constraint_id: "math.undefined_part"`, `agent_message: "A side of the identity is undefined"`, and no attestation. Previous versions returned a signed disproof.

**Response (parse error):** Input the parser refuses returns `BLOCKED` with `constraint_id: "math.parse_error"`. The parser's own error text is not returned. Previous versions returned `api.math.execution_error` with `Internal verification error`.

```json theme={null}
{
  "status": "BLOCKED",
  "is_valid": false,
  "agent_message": "Expression could not be parsed",
  "constraint_id": "math.parse_error",
  "proof_ref": null,
  "admission": "BLOCKED"
}
```

Leibniz notation (`d/dx`, `dy/dx`, `d^2y/dx^2`) gets the same `BLOCKED` result with the message `Expression uses d/dx notation, or a name read as d times a variable, which cannot be read unambiguously; write diff(f, x) for a derivative`.

The audit log row's `is_verified` follows `is_valid`, so a proven-false identity is logged as not verified.

**Response (expression):**

```json theme={null}
{
  "status": "VERIFIED",
  "is_valid": true,
  "value": 4.0,
  "exact_value": "4",
  "simplified": "4",
  "original": "2 + 2",
  "agent_message": "Expression evaluated",
  "proof_ref": "sha256:...",
  "attestation": "eyJ...",
  "admission": "ADMIT"
}
```

Symbolic expressions that cannot be evaluated to a numeric value include `is_symbolic: true`. Expressions involving complex numbers include `is_complex: true`.

**Ambiguous expressions:** Expressions with implicit multiplication after division (e.g., `1/2(3+1)`) are rejected with `is_valid: false`, `result: false`, `status: "BLOCKED"`, and `admission: "BLOCKED"`. The response includes `warning: "ambiguous"` and an `agent_message` explaining why. Rewrite the expression with explicit parentheses or a `*` operator to remove the ambiguity.

```json theme={null}
{
  "is_valid": false,
  "result": false,
  "status": "BLOCKED",
  "warning": "ambiguous",
  "agent_message": "Expression may be ambiguous due to implicit multiplication after division",
  "simplified": "...",
  "admission": "BLOCKED"
}
```

**Error cases:** Division by zero, `log(0)`, and square root of negative numbers in the real domain return `status: "BLOCKED"`, `is_valid: false`, and `admission: "BLOCKED"`, with a descriptive `error` and `agent_message`.

**Tolerance bounding:** This endpoint takes no `tolerance`. The bound applies to the engine method `VerificationEngine.verify_math(expression, expected, tolerance)`, which returns an engine dictionary without `admission`. When it verifies an expression against an expected value with a `tolerance` parameter, the engine enforces a deterministic upper bound on the tolerance. The maximum allowed tolerance is `max(0.01, abs(calculated_value) * 0.01)`. If the requested tolerance exceeds this bound, the request returns a `BLOCKED` status. Invalid tolerance values (negative, `NaN`, `Infinity`, or non-numeric) are also rejected with `BLOCKED`.

**Engine dictionary (tolerance exceeded):**

```json theme={null}
{
  "is_correct": false,
  "error": "Tolerance exceeds deterministic verification bound",
  "requested_tolerance": "1000",
  "max_allowed_tolerance": "0.02000000",
  "calculated_value": "2.000000",
  "precision_mode": "decimal",
  "status": "BLOCKED"
}
```

| Field | Type | Description |
| - | - | - |
| `requested_tolerance` | string | The tolerance value that was submitted |
| `max_allowed_tolerance` | string | The maximum tolerance the engine allows for the computed result |
| `calculated_value` | string | The engine's computed result |
| `precision_mode` | string | `"decimal"` or `"float"` depending on the computation path |

***

### POST /verify/logic

Verify logical constraints. Routes through the QWED Control Plane.

**Request:**

```json theme={null}
{
  "query": "Is there an x greater than 5 and less than 10?",
  "provider": "openai"
}
```

| Parameter | Type | Required | Description |
| - | - | - | - |
| `query` | string | Yes | A logic question in natural language. The server translates it into the [QWED DSL](https://docs.qwedai.tech/api/dsl-reference.md) with the LLM provider and checks the DSL with Z3. It does not check DSL directly. |
| `provider` | string | No | Preferred LLM provider |

**Response:**

The response is a [`DiagnosticResult`](https://docs.qwedai.tech/advanced/diagnostics), with the control plane's own output under `developer_fields` and also copied to the top level. A `VERIFIED` result carries a signed `attestation`.

```json theme={null}
{
  "status": "VERIFIED",
  "agent_message": "Logic constraints are satisfiable",
  "developer_fields": {
    "status": "SAT",
    "model": {"x": "6", "y": "9"},
    "dsl_code": "(AND (GT x 5) (LT x 10))",
    "provider_used": "openai_compat"
  },
  "proof_ref": "sha256:4c1e…",
  "is_authoritative": true,
  "attestation": "eyJ…",
  "model": {"x": "6", "y": "9"},
  "dsl_code": "(AND (GT x 5) (LT x 10))",
  "provider_used": "openai_compat"
}
```

| `status` | Solver result (`developer_fields.status`) | Meaning |
| - | - | - |
| `VERIFIED` | `SAT` | Satisfiable: a solution exists, in `model` |
| `UNVERIFIABLE` | `UNSAT` | Unsatisfiable: no solution exists |
| `BLOCKED` | anything else (`ERROR`, `BLOCKED`, `UNKNOWN`) | Blocked by security policy, a translation or solver error, or a timeout |

The route makes no admission decision, so the response has no `admission`. The `provider_used` field shows which LLM provider handled the translation, in success and error responses alike.

**Error response:**

```json theme={null}
{
  "status": "BLOCKED",
  "agent_message": "Internal verification error",
  "developer_fields": {
    "constraint_id": "api.logic.execution_error",
    "provider_used": "openai_compat"
  },
  "proof_ref": null,
  "is_authoritative": false,
  "constraint_id": "api.logic.execution_error",
  "provider_used": "openai_compat"
}
```

The provider translation step runs under `QWED_PROVIDER_TIMEOUT` (default 30 seconds) and the Z3 solve under `QWED_Z3_TIMEOUT` (default 15 seconds). A timeout returns `BLOCKED`.

***

### POST /verify/code

> [!NOTE]
>
> Updated in v7.0.0 (breaking)

Check code for security vulnerabilities using AST analysis. Returns the unified [`DiagnosticResult`](https://docs.qwedai.tech/advanced/diagnostics) shape plus a separate `admission` decision.

**Request:**

```json theme={null}
{
  "code": "import os\nos.system('rm -rf /')",
  "language": "python"
}
```

| Parameter | Type | Required | Description |
| - | - | - | - |
| `code` | string | Yes | Source code to analyze |
| `language` | string | No | Programming language (default: `python`) |

**Response (unsafe code — proven unsafe, not admitted):**

```json theme={null}
{
  "status": "VERIFIED",
  "agent_message": "The code failed security verification and is not safe to use.",
  "developer_fields": {
    "constraint_id": "code_verifier.code_unsafe",
    "is_safe": false,
    "is_valid": false,
    "language": "python",
    "critical_count": 1,
    "issues": [
      {
        "severity": "CRITICAL",
        "type": "os.system",
        "line_number": 2,
        "description": "Shell command execution"
      }
    ]
  },
  "proof_ref": "sha256:...",
  "is_authoritative": true,
  "admission": "BLOCKED"
}
```

| Field | Type | Description |
| - | - | - |
| `status` | string | `VERIFIED` when the scan completed (safe **or** unsafe code); `BLOCKED` when verification itself failed (empty code, unsupported language, internal error) |
| `admission` | string | `ADMIT` or `BLOCKED` — the fail-closed admission decision. `ADMIT` only when `status` is `VERIFIED` **and** `developer_fields.is_valid` is `true` |
| `developer_fields.is_valid` | boolean | The safety gate — `true` only when no critical issue was found |
| `developer_fields.issues` | array | Detected vulnerabilities with severity, type, line number, and description |
| `proof_ref` | string | Present on `VERIFIED` results (including verified-unsafe); `null` on `BLOCKED` |

> [!WARNING]
>
> **Breaking change (v7.0.0):** proven-unsafe code now returns HTTP 200 with `status: "VERIFIED"` (previously `BLOCKED`). `VERIFIED` answers "was the code checked?", not "is it safe to execute?". Consumers that gated on `status` for safety must switch to the `admission` field or `developer_fields.is_valid`.

| Status | Description |
| - | - |
| 503 | `Code verification timed out.` The scan exceeded `QWED_CODE_VERIFY_TIMEOUT` (default 10 seconds) |

***

### POST /verify/sql

> [!NOTE]
>
> Updated in v7.0.0 (breaking)

Validate SQL queries against a provided schema. Returns the unified [`DiagnosticResult`](https://docs.qwedai.tech/advanced/diagnostics) shape plus a separate `admission` decision.

**Request:**

```json theme={null}
{
  "query": "SELECT * FROM users WHERE id = 1",
  "schema_ddl": "CREATE TABLE users (id INT, name TEXT)",
  "dialect": "sqlite"
}
```

| Parameter | Type | Required | Description |
| - | - | - | - |
| `query` | string | Yes | SQL query to validate |
| `schema_ddl` | string | Yes | DDL schema definition (e.g., `CREATE TABLE` statements) |
| `dialect` | string | No | SQL dialect (default: `sqlite`) |

**Response (safe query):**

```json theme={null}
{
  "status": "VERIFIED",
  "agent_message": "The SQL query passed verification and is safe to execute.",
  "developer_fields": {
    "constraint_id": "sql_verifier.sql_valid",
    "is_valid": true,
    "malicious_classification": false
  },
  "proof_ref": "sha256:...",
  "is_authoritative": true,
  "admission": "ADMIT"
}
```

A proven-malicious query returns `status: "VERIFIED"` with `developer_fields.is_valid: false`, `developer_fields.malicious_classification: true`, and `admission: "BLOCKED"` — proving malice is a successful proof, so the truth verdict is preserved while admission is denied. `BLOCKED` status is reserved for verification failures: `sql_verifier.parse_error`, `sql_verifier.schema_parse_error`, `sql_verifier.complexity_limit_exceeded`, `sql_verifier.execution_error`, and, from v9.0.0, `sql_verifier.unevaluated_predicate` (a filter predicate that reads no data and can't be worked out). See the [SQL engine](https://docs.qwedai.tech/engines/sql.md) page for the full contract.

***

### POST /verify/fact

Verify a factual claim against a provided context.

**Request:**

```json theme={null}
{
  "claim": "Paris is the capital of France",
  "context": "France is a country in Western Europe. Its capital is Paris.",
  "provider": "anthropic"
}
```

| Parameter | Type | Required | Description |
| - | - | - | - |
| `claim` | string | Yes | The factual statement to verify |
| `context` | string | Yes | Reference text to verify the claim against |
| `provider` | string | No | Preferred LLM provider |

**Response:**

```json theme={null}
{
  "verdict": "SUPPORTED",
  "confidence": 0.98,
  "reasoning": "The context explicitly states that the capital of France is Paris"
}
```

| Verdict | Meaning |
| - | - |
| `SUPPORTED` | Claim is supported by the context |
| `REFUTED` | Claim contradicts the context |
| `INCONCLUSIVE` | Not enough evidence in the context |

| Status | Description |
| - | - |
| 503 | `Fact verification timed out.` The provider call exceeded `QWED_PROVIDER_TIMEOUT` (default 30 seconds) |

***

### POST /verify/consensus

> [!NOTE]
>
> New in v4.0.0 · Updated in v5.0.0

Multi-engine consensus verification. Runs the query through multiple verification engines and requires agreement above a confidence threshold. This endpoint is rate-limited per API key.

Mathematical expressions are parsed and compared using `Decimal` arithmetic internally, which avoids floating-point drift when engines cross-check results.

> [!NOTE]
>
> The fact engine is excluded from automatic engine selection during consensus verification. Fact-based verification requires external context and will return an error if invoked without it, preventing self-referential consensus loops.

**Request:**

```json theme={null}
{
  "query": "The square root of 144 is 12",
  "verification_mode": "high",
  "min_confidence": 0.95
}
```

| Parameter | Type | Required | Default | Description |
| - | - | - | - | - |
| `query` | string | Yes | — | The claim to verify |
| `verification_mode` | string | No | `single` | `single`, `high`, or `maximum` |
| `min_confidence` | float | No | `0.95` | Minimum confidence threshold, 0–1 |

**Verification modes:**

| Mode | Engines | Use case |
| - | - | - |
| `single` | 1 | Fast, single engine verification |
| `high` | 2 | Higher confidence for important claims |
| `maximum` | 3+ | Critical domains (medical, financial) |

**Response:**

```json theme={null}
{
  "final_answer": "12",
  "confidence": 98.5,
  "engines_used": 2,
  "agreement_status": "UNANIMOUS",
  "verification_chain": [
    {
      "engine": "math",
      "method": "symbolic",
      "result": "12",
      "confidence": 99.0,
      "latency_ms": 12.5,
      "success": true,
      "status": "VERIFIED"
    }
  ],
  "total_latency_ms": 45.2,
  "meets_requirement": true
}
```

| Field | Type | Description |
| - | - | - |
| `confidence` | float | Confidence as a percentage (0–100) |
| `verification_chain` | array | Detailed results from each engine |
| `verification_chain[].status` | string | Trust-boundary status from that engine — `VERIFIED`, `UNVERIFIABLE`, or `BLOCKED` |
| `meets_requirement` | boolean | True only when consensus is `VERIFIED` at the trust boundary **and** `confidence` meets `min_confidence`. Confidence alone is never sufficient. |

> [!NOTE]
>
> Only unanimous `VERIFIED` engine outcomes can produce a `VERIFIED` consensus. Advisory-only sub-engines (statistical computation, code execution) always return `UNVERIFIABLE`, so a query answered solely by those paths never satisfies `meets_requirement`. See the [Consensus engine](https://docs.qwedai.tech/engines/consensus.md#sub-engine-status-contract) page for the full sub-engine contract.

| Status | Description |
| - | - |
| 400 | Invalid verification mode |
| 422 | Consensus confidence below the requested `min_confidence` threshold |
| 503 | Secure Docker sandbox unavailable — required for Python engine in `high`/`maximum` mode |

Each server process runs at most `QWED_SANDBOX_MAX_CONCURRENT` sandbox containers at once (default 4). If a Python engine run can't get a sandbox slot within `QWED_SANDBOX_ACQUIRE_TIMEOUT` (default 10 seconds), QWED rejects the run with `Sandbox capacity reached — retry shortly.` instead of starting another container. A capacity rejection never produces a `VERIFIED` result.

***

### POST /verify/image

Verify claims about image content. Accepts multipart form data with an image file (max 10 MB). Supported formats: PNG, JPEG, GIF, WebP.

**Request:**

```bash theme={null}
curl -X POST http://localhost:8000/verify/image \
  -H "X-API-Key: qwed_your_key" \
  -F "image=@photo.jpg" \
  -F "claim=The image is 800x600 pixels"
```

| Parameter | Type | Required | Description |
| - | - | - | - |
| `image` | file | Yes | Image file (max 10 MB; PNG, JPEG, GIF, WebP) |
| `claim` | string | Yes | The claim about the image to verify |

**Response:**

```json theme={null}
{
  "verdict": "SUPPORTED",
  "confidence": 0.95,
  "reasoning": "Image dimensions match the claimed resolution",
  "methods_used": ["metadata_analysis", "pixel_inspection"]
}
```

| Verdict | Meaning |
| - | - |
| `SUPPORTED` | Claim is confirmed by image analysis |
| `REFUTED` | Claim contradicts image analysis |
| `INCONCLUSIVE` | Cannot determine from available methods |
| `VLM_REQUIRED` | Visual Language Model needed for deeper analysis |

***

### POST /verify/stats

Verify statistical claims against CSV data. Accepts multipart form data with a CSV file.

Statistical code execution requires the secure Docker sandbox. If Docker is unavailable, the endpoint returns HTTP 503. If the verification is blocked by a security policy, it returns HTTP 403.

**Request:**

```bash theme={null}
curl -X POST http://localhost:8000/verify/stats \
  -H "X-API-Key: qwed_your_key" \
  -F "file=@data.csv" \
  -F "query=The average salary is above 50000"
```

| Parameter | Type | Required | Description |
| - | - | - | - |
| `file` | file | Yes | CSV data file |
| `query` | string | Yes | Statistical claim to verify |

**Response (execution succeeded):**

```json theme={null}
{
  "status": "UNVERIFIABLE",
  "agent_message": "Statistical analysis completed, but the claim could not be deterministically verified.",
  "developer_fields": {
    "constraint_id": "stats_verifier.claim_not_verified",
    "is_valid": false,
    "observed_result": 52340.5,
    "columns": ["salary"],
    "dataset_sha256": "9f2c...",
    "sandbox_type": "docker",
    "execution_time_ms": 412.7
  },
  "proof_ref": null,
  "is_authoritative": false
}
```

> [!WARNING]
>
> **Breaking change (v7.0.0):** the endpoint returns the unified [`DiagnosticResult`](https://docs.qwedai.tech/advanced/diagnostics) shape instead of the legacy `{"status": "SUCCESS" | "ERROR" | "BLOCKED", "result": ..., "code": ...}` shape. Successful execution now reports `status: "UNVERIFIABLE"` with the observed value in `developer_fields.observed_result` — execution success alone is never presented as a proven claim, so `VERIFIED` is not emitted from this endpoint.

Failure states return `status: "BLOCKED"` with a `constraint_id` of `stats_verifier.validation_error` (translation or security validation failed), `stats_verifier.execution_failure` (sandbox execution failed), or `stats_verifier.runtime_unavailable` (Docker unavailable). Blocked results carry no `proof_ref`.

| Status | Description |
| - | - |
| 403 | Verification blocked by security policy (e.g., generated code failed AST safety checks) |
| 503 | Secure Docker sandbox unavailable — statistical verification requires Docker |

> [!NOTE]
>
> Statistical verification requires a running Docker daemon. If Docker is unavailable, the endpoint returns HTTP 503 instead of falling back to in-process execution. See the [Stats engine](https://docs.qwedai.tech/engines/stats.md) page for details.
>
> When the sandbox is at capacity (`QWED_SANDBOX_MAX_CONCURRENT` containers already running) and no slot frees within `QWED_SANDBOX_ACQUIRE_TIMEOUT`, the execution is rejected with `Sandbox capacity reached — retry shortly.` and the result is `BLOCKED`.

***

### POST /verify/process

> [!NOTE]
>
> New in v4.0.1

Verify the structural integrity of LLM reasoning traces. Supports IRAC structural compliance checking and custom milestone validation with decimal scoring.

**Request (IRAC mode):**

```json theme={null}
{
  "trace": "The issue is whether the contract was breached. The rule is Article 2 of the UCC. Applying this rule, the defendant failed to deliver on time. In conclusion, breach occurred.",
  "mode": "irac"
}
```

**Request (milestones mode):**

```json theme={null}
{
  "trace": "Risk assessment complete. Compliance check passed. Implementation timeline defined.",
  "mode": "milestones",
  "milestones": ["risk assessment", "compliance check", "implementation"]
}
```

| Parameter | Type | Required | Default | Description |
| - | - | - | - | - |
| `trace` | string | Yes | — | The LLM reasoning trace to validate |
| `mode` | string | No | `irac` | `irac` or `milestones` |
| `milestones` | string\[] | Conditional | — | Required when `mode` is `milestones` |

**Response (IRAC mode):**

```json theme={null}
{
  "verified": true,
  "score": 1.0,
  "missing_steps": [],
  "mechanism": "Regex Pattern Matching (Deterministic)"
}
```

**Response (milestones mode):**

```json theme={null}
{
  "verified": true,
  "process_rate": 1.0,
  "missed_milestones": []
}
```

| Status | Description |
| - | - |
| 400 | Invalid mode or missing `milestones` when `mode` is `milestones` |

***

### POST /verify/rag

> [!NOTE]
>
> New in v4.0.1

Verify that retrieved RAG chunks originate from the expected source document. Prevents Document-Level Retrieval Mismatch (DRM) hallucinations in RAG pipelines.

**Request:**

```json theme={null}
{
  "target_document_id": "contract_nda_v2",
  "chunks": [
    { "id": "c1", "metadata": { "document_id": "contract_nda_v2" } },
    { "id": "c2", "metadata": { "document_id": "contract_nda_v1" } }
  ],
  "max_drm_rate": "0"
}
```

| Parameter | Type | Required | Default | Description |
| - | - | - | - | - |
| `target_document_id` | string | Yes | — | Expected source document ID |
| `chunks` | object\[] | Yes | — | Array of chunk objects with metadata |
| `max_drm_rate` | string | No | `"0"` | Maximum tolerable mismatch fraction as a `Fraction`-compatible string (e.g. `"0"`, `"1/10"`) |

> [!NOTE]
>
> `max_drm_rate` accepts only string values for symbolic precision. Use fraction notation like `"1/10"` instead of `0.1`.

**Response:**

```json theme={null}
{
  "verified": false,
  "risk": "DOCUMENT_RETRIEVAL_MISMATCH",
  "drm_rate": 0.5,
  "chunks_checked": 2,
  "mismatched_count": 1
}
```

| Status | Description |
| - | - |
| 400 | Invalid request payload (empty `target_document_id`, empty `chunks`, or invalid `max_drm_rate` value) |

***

### POST /verify/batch

Verify multiple items in a single request. Processes all items concurrently and returns aggregated results. Maximum 100 items per batch.

**Request:**

```json theme={null}
{
  "items": [
    {"query": "What is 2+2?", "type": "natural_language"},
    {"query": "(AND (GT x 5) (LT y 10))", "type": "logic"},
    {"query": "x**2 + 2*x + 1 = (x+1)**2", "type": "math"}
  ]
}
```

| Parameter | Type | Required | Description |
| - | - | - | - |
| `items` | array | Yes | Array of verification items (max 100) |
| `items[].query` | string | Yes | The claim to verify |
| `items[].type` | string | No | Verification type: `natural_language`, `logic`, `math`, `code`, `fact`, `sql` (default: `natural_language`) |
| `items[].params` | object | No | Additional parameters for the verification |

**Response:**

Each entry in `items` is a per-item response. An item's `result` is what the item type's own `/verify/*` route returns, so batch and single-item verifications share one contract. Every item type takes its route's trust step: QWED signs each `VERIFIED` result and enforces the attestation as mandatory. A `VERIFIED` item whose attestation cannot be issued, or does not match, is returned `BLOCKED` with no `proof_ref` (see [Attestation enforcement](#attestation-enforcement)).

Math, logic, and natural-language items return the [`DiagnosticResult`](https://docs.qwedai.tech/advanced/diagnostics) record under `result`:

* `result.status`: `VERIFIED`, `UNVERIFIABLE`, or `BLOCKED`.
* `result.agent_message`: short human-readable summary safe to surface to the caller.
* `result.developer_fields`: engine detail (including the legacy `is_valid` flag for backward compatibility).
* `result.proof_ref`: cryptographic hash of the retained proof artifact. Present only when `result.status == VERIFIED`; `null` otherwise. **This is the authority bit.** Downstream gates must reject any item whose `proof_ref` is `null`.
* `result.admission`: `ADMIT` or `BLOCKED` on math, SQL, and code items, the same decision `POST /verify/math`, `POST /verify/sql`, and `POST /verify/code` return. Natural-language items always carry `BLOCKED`. Logic and fact items carry no admission. Admit an item only when `admission` is `ADMIT`, `status` is `VERIFIED`, and `proof_ref` is set. A proven-malicious query, proven-unsafe code, or a math claim proven false is `VERIFIED` with admission `BLOCKED`.

| Item type | `result` |
| - | - |
| `math` | The `POST /verify/math` verdict. See [Math items](#math-items-proof-vs-simplification) below. |
| `logic` | The `POST /verify/logic` response. `VERIFIED` with a `proof_ref` when the constraints are satisfiable (SAT), `UNVERIFIABLE` when they are unsatisfiable (UNSAT), and `BLOCKED` otherwise. The control plane's own output (`status: "SAT"`, `model`, `provider_used`) stays under `result.developer_fields`. |
| `natural_language` | The `POST /verify/natural_language` response, with `admission`, which is always `BLOCKED`. Only the verdict is kept: `final_answer`, `translation`, and `trust_boundary` are not returned. |
| `code`, `fact`, `sql` | The item type's route result, unchanged apart from the trust step. A code item's `admission` is decided on the enforced verdict. |

QWED stores each math and SQL item's admission in its audit row, so `/audit/logs` shows it. Rows written before this change keep `admission: null`.

A SQL item must include `params.schema_ddl`. An item without it is marked `failed` with the error `Missing 'schema_ddl'`, the same message `POST /verify/sql` returns, and carries no admission.

> [!WARNING]
>
> **Breaking change.** Batch logic items no longer report `SAT` or `UNSAT` as `result.status`. Gate on `result.status == "VERIFIED"` and a non-null `result.proof_ref`. Read the solver's raw status from `result.developer_fields.status` if you need it.

A logic item, for example:

```json theme={null}
{
  "id": "batch_abc123-1",
  "query": "(AND (GT x 5) (LT y 10))",
  "type": "logic",
  "status": "completed",
  "result": {
    "status": "VERIFIED",
    "agent_message": "Logic constraints are satisfiable",
    "developer_fields": {
      "status": "SAT",
      "model": {"x": "6", "y": "9"},
      "provider_used": "openai_compat"
    },
    "proof_ref": "sha256:4c1e…",
    "is_authoritative": true
  },
  "latency_ms": 310.4
}
```

The full response for two math items:

```json theme={null}
{
  "job_id": "batch_abc123",
  "status": "completed",
  "progress_percent": 100.0,
  "total_items": 2,
  "completed_items": 2,
  "failed_items": 0,
  "items": [
    {
      "id": "batch_abc123-0",
      "query": "x**2 + 2*x + 1 = (x+1)**2",
      "type": "math",
      "status": "completed",
      "result": {
        "status": "VERIFIED",
        "agent_message": "Identity is true",
        "query": "x**2 + 2*x + 1 = (x+1)**2",
        "type": "math",
        "is_valid": true,
        "result": true,
        "left_side": "x**2 + 2*x + 1",
        "right_side": "(x + 1)**2",
        "simplified_difference": "0",
        "diff": "0",
        "developer_fields": {
          "query": "x**2 + 2*x + 1 = (x+1)**2",
          "type": "math",
          "is_valid": true,
          "result": true,
          "left_side": "x**2 + 2*x + 1",
          "right_side": "(x + 1)**2",
          "simplified_difference": "0",
          "diff": "0"
        },
        "proof_ref": "sha256:9f2c…",
        "attestation": "eyJ...",
        "admission": "ADMIT",
        "is_authoritative": true
      },
      "latency_ms": 12.3
    },
    {
      "id": "batch_abc123-1",
      "query": "x + x",
      "type": "math",
      "status": "completed",
      "result": {
        "status": "UNVERIFIABLE",
        "agent_message": "Expression simplified, but no equality or proof claim was provided",
        "developer_fields": {
          "query": "x + x",
          "type": "math",
          "is_valid": false,
          "simplified": "2*x"
        },
        "proof_ref": null,
        "admission": "BLOCKED",
        "is_authoritative": false
      },
      "latency_ms": 8.1
    }
  ]
}
```

#### Math items: proof vs. simplification

Math items build the identity verdict with the same function as `POST /verify/math`, so an equality claim gets the same verdict on both routes. They take the same trust step: QWED signs a `VERIFIED` identity, true or proven false, and enforces the attestation as mandatory. The attestation's engine is `math`.

Each equality item carries the route's messages (`Identity is true`, `Identity is false`, and so on) and fields (`left_side`, `right_side`, `simplified_difference`, `result`), and keeps its own `query`, `type`, and `diff`.

| Result | `status` | `is_valid` | `proof_ref` | `admission` | When |
| - | - | - | - | - | - |
| Verified identity | `VERIFIED` | `true` | `sha256:...` | `ADMIT` | Equality claim (e.g., `x**2 + 2*x + 1 = (x+1)**2`) simplifies to `0` **and** attestation signing succeeds. |
| Proven false | `VERIFIED` | `false` | `sha256:...` | `BLOCKED` | SymPy decides the difference is non-zero (e.g., `2 + 2 = 5`). The item carries `attestation`. `diff` shows the non-zero difference. |
| Undecided | `UNVERIFIABLE` | `false` | `null` | `BLOCKED` | SymPy can't decide the difference, or the difference only holds float residue. |
| Undefined part | `UNVERIFIABLE` | `false` | `null` | `BLOCKED` | A side has a part that evaluates to `zoo` or `nan`, such as `log(0)**0 = 2`. `constraint_id` is `math.undefined_part`, as on `POST /verify/math`. |
| Simplification only | `UNVERIFIABLE` | `false` | `null` | `BLOCKED` | Expression without an equality (e.g., `x + x`). `developer_fields.simplified` contains the simplified form. |
| Domain-restricted identity | `UNVERIFIABLE` | `false` | `null` | `BLOCKED` | Equality whose sides are undefined at different points, such as `x/x = 1`. `constraint_id` is `math.domain_restricted_identity`, as on `POST /verify/math`. |
| Parse error | `BLOCKED` | `false` | `null` | `BLOCKED` | The parser refuses the expression, including Leibniz notation such as `d/dx`. `constraint_id` is `math.parse_error`, as on `POST /verify/math`. |
| Signing failed | `BLOCKED` | n/a | `null` | `BLOCKED` | Verified identity whose attestation could not be issued. See below. |

Previously, a batch math claim proven false was `UNVERIFIABLE` with no `proof_ref`, and an expression the parser refused was a `failed` item carrying the parser's error text.

If attestation signing fails on an otherwise-verified identity, the item is `BLOCKED`, as on `POST /verify/math`. Previously it was downgraded to `UNVERIFIABLE`. A math result is never returned as `VERIFIED` without a signed proof.

#### Attestation enforcement

When QWED cannot issue or match the attestation for a `VERIFIED` result, the result is `BLOCKED` (fail closed). This applies to every batch item type and every verify route. The verdict that was not admitted stays beside the block in `developer_fields`:

```json theme={null}
{
  "status": "BLOCKED",
  "agent_message": "Attestation unavailable — cannot sign VERIFIED result",
  "developer_fields": {
    "constraint_id": "api.attestation.signing_error",
    "attestation_error": "SIGNING_FAILURE",
    "engine": "math",
    "verdict_status": "VERIFIED",
    "verdict_proof_ref": "sha256:9f2c…"
  },
  "proof_ref": null,
  "is_authoritative": false
}
```

Don't admit a result on `verdict_status` or `verdict_proof_ref`. They record what the engine concluded for the audit trail, not an admitted verdict. See [Fail-closed contract](https://docs.qwedai.tech/advanced/attestations.md#fail-closed-contract).

> [!WARNING]
>
> Bare expressions (no `=`) always return `UNVERIFIABLE`. A successful symbolic simplification is not a verified claim. To get a `VERIFIED` outcome from a math item, submit an equality (e.g., `x + x = 2*x`).

> [!NOTE]
>
> The pre-v6.0 top-level `is_valid` field is preserved inside `developer_fields.is_valid` for backward compatibility, but new code should gate on `result.admission == "ADMIT"`, `result.status == "VERIFIED"`, and the presence of `result.proof_ref`. A math claim proven false is `VERIFIED` with a `proof_ref`, so `status` alone does not mean the claim holds. The batch response does not include a pass/fail aggregate because a boolean cannot represent the three-status verdict — count `items[].result.status == "VERIFIED"` entries directly if you need a success ratio.

Each item runs under `QWED_BATCH_ITEM_TIMEOUT` (default 60 seconds). An item that times out is marked `failed` with its error recorded, and the rest of the job still completes.

**Audit trail:** QWED appends one signed, hash-chained audit row per item, including failed items. Each row stores the item's query and a result with `batch_job_id`, `batch_item_id`, `batch_item_type`, `item_status`, and `details` (the item's `result` or `error`). The row's domain and `is_verified` follow the rule of the item type's own `/verify/*` route. For example, natural-language items store domain `NATURAL_LANGUAGE` and math items store `MATH`. If the audit append for an item fails, QWED marks that item `failed`, returns `"result": null` with `"error": "audit persistence failed"`, and counts it in `failed_items`. No item result is returned without its audit row.

| Status | Description |
| - | - |
| 400 | No items, or more than 100 items |
| 503 | `Batch capacity reached — retry shortly.` The job store is full of active jobs. See [Availability limits](https://docs.qwedai.tech/advanced/self-hosting.md#availability-limits) |

### GET /verify/batch/{job_id}

Get the status and results of a batch verification job. Use this to poll results when processing large batches.

**Response:**

```json theme={null}
{
  "job_id": "batch_abc123",
  "status": "completed",
  "items": [...]
}
```

| Status | Description |
| - | - |
| 403 | Access denied — job belongs to a different organization |
| 404 | Job not found |

***

## Verification Context endpoints

> [!NOTE]
>
> New in v7.1.0

These endpoints work with [Verification Context](https://docs.qwedai.tech/specs/verification-context.md) documents, the standardized JSON record of a verification. All three require an API key and are rate-limited per key.

The server signs every document it issues. `from-diagnostic` returns a [v1.1 document](https://docs.qwedai.tech/specs/verification-context.md#version-1-1-and-the-issuer-attestation) whose `attestation` field is the server's ES256 signature over every other field. By default, `validate` and `resolve` check that signature before they answer, so a document someone else built or edited is refused even when its `proof_ref` resolves.

### POST /verification-context/from-diagnostic

Convert a [`DiagnosticResult`](https://docs.qwedai.tech/advanced/diagnostics) into a schema-valid Verification Context document. Use this when you have an engine verdict and need the standardized VC record for auditing or downstream gating.

**Request:**

```json theme={null}
{
  "query": "x**2 + 2*x + 1 = (x+1)**2",
  "verifier": "MathVerifier",
  "diagnostic": {
    "status": "VERIFIED",
    "agent_message": "Identity verified",
    "developer_fields": {"is_valid": true},
    "proof_ref": "sha256:9f2c…"
  },
  "verifier_version": "7.1.0",
  "attestation_token": "<ES256 JWT attestation>"
}
```

| Parameter | Type | Required | Description |
| - | - | - | - |
| `query` | string | Yes | The formal statement that was verified |
| `verifier` | string | Yes | Name of the engine that produced the diagnostic |
| `diagnostic` | object | Yes | The `DiagnosticResult` record (`status` / `agent_message` / `developer_fields` / `proof_ref`) |
| `verifier_version` | string | No | Engine version. Defaults to the installed `qwed` package version |
| `attestation_token` | string | No | Signed attestation for the diagnostic. Required to keep a `VERIFIED` status |

**Response:** a v1.1 Verification Context document (`spec_version`, `object`, `context`, `verdict`, `attestation`), signed by the server.

**Fail-closed behavior:**

* A malformed `diagnostic` payload is converted to a `BLOCKED` document instead of being rejected, so the audit trail records the failure.
* A `VERIFIED` diagnostic without an `attestation_token` fails closed to `BLOCKED` (`trust_gate.mandatory_attestation_missing`). A supplied invalid token fails closed to `BLOCKED` (`trust_gate.invalid_attestation_token`). A valid token with mismatched claims uses the corresponding `trust_gate.claims_*` code. Attestation is enforced through the same trust boundary as `/verify/*`.
* The server records the diagnostic but does not vouch for it. The caller's `agent_message` and `developer_fields` are kept under `developer_fields.caller_supplied` and decide nothing, `is_valid` included.
* The admission is `DENY` unless `attestation_token` also signs `ADMIT` for this verdict (`qwed.admission`), as the token of an answer the server admitted does. An admitted document names the engine that signed the token, and this server's version, as its verifier. Your `verifier` and `verifier_version` are kept under `caller_supplied`.

**Audit row:** Each successful call writes one audit row; a `422` or a signing failure writes none. The row stores the signed document exactly as returned, so its attestation matches the stored document: read back from `GET /audit/logs`, it verifies with [`POST /verification-context/validate`](#post-%2Fverification-context%2Fvalidate) until the attestation expires (24 hours by default) and while the server keeps the signing key. Only the row's `query` is redacted for personal data (emails, phone numbers, SSNs, IP addresses). The redaction matches patterns and misses some forms, such as a phone number written `(415) 555-1212` or an IPv6 address, so treat the `query` column as possibly holding personal data.

> [!WARNING]
>
> The stored document keeps any personal data you put in `query` or `diagnostic`, including the diagnostic's `agent_message` and `developer_fields`, which are stored as sent. To keep it out of the audit log, mask personal data in the query of the original verification and send that query unchanged to `from-diagnostic`. For a `VERIFIED` diagnostic the attestation covers the query, so masking it only here makes the document `BLOCKED`. Rows written before this change hold a redacted copy of the document, which may not verify against its attestation: it fails when the redaction changed a signed field.

| Status | Description |
| - | - |
| 422 | The resulting document failed Verification Context validation, or the canonical encoding refuses it (for example, a lone surrogate in a string) |
| 500 | Audit persistence, or attestation verification or signing, failed |

### POST /verification-context/validate

Validate a Verification Context document against the JSON Schema of its `spec_version` (v1.0 or v1.1), including the verdict/`proof_ref` invariants. By default, the server first checks the document's attestation with its own signing key.

**Request:**

```json theme={null}
{
  "document": {
    "spec_version": "1.1",
    "object": {"formal_statement": "x**2 + 2*x + 1 = (x+1)**2"},
    "context": {
      "interpretation": {"theory": "real-closed field", "logic": "first-order logic"},
      "proof": {"verifier": "qwed", "verifier_version": "7.1.0"},
      "evidence": {"evidence": {"status": "VERIFIED"}, "proof_ref": "sha256:9f2c…"},
      "decision": {"admission": "ADMIT"}
    },
    "verdict": "VERIFIED",
    "attestation": "<ES256 JWT from from-diagnostic>"
  }
}
```

| Parameter | Type | Required | Description |
| - | - | - | - |
| `document` | object | Yes | The Verification Context document |
| `require_attestation` | boolean | No | Default `true`. The document must carry an `attestation` that the server's key verifies and that signs this document. Set `false` to check the schema alone, for example for a v1.0 document |

**Response:**

```json theme={null}
{"valid": true}
```

A document that fails the attestation check or validation returns HTTP 200 with `valid: false` and an `error`. Gate on the `valid` field. A server-side failure is a `500` instead (see the status table below), so treat any non-200 response as not validated.

| `error` | Cause |
| - | - |
| `attestation_required` | The document carries no `attestation` |
| `attestation_invalid` | The attestation does not verify with the server's key, or does not sign this document (it was edited after signing, or carries another document's token) |
| `validation_failed` | Schema or invariant violation |

> [!WARNING]
>
> **Behavior change.** A v1.0 document has no `attestation`, so it now returns `attestation_required` unless you send `"require_attestation": false`. Older SDK versions don't send the flag.

| Status | Description |
| - | - |
| 500 | Audit persistence failed, or the server could not load its signing key to check the attestation |

### POST /verification-context/resolve

Resolve the `proof_ref` evidence commitment of a Verification Context document. The server removes the stored `proof_ref`, re-derives the SHA-256 commitment over the canonical (RFC 8785) bound payload, and compares it to the stored value.

A `proof_ref` is an unkeyed commitment: anyone can compute one for a document they built. So, by default, the server first checks the document's attestation, as for `/validate`, and takes the same `require_attestation` parameter.

**Request:**

```json theme={null}
{
  "document": {
    "spec_version": "1.1",
    "object": {"formal_statement": "x**2 + 2*x + 1 = (x+1)**2"},
    "context": {
      "interpretation": {"theory": "real-closed field", "logic": "first-order logic"},
      "proof": {"verifier": "qwed", "verifier_version": "7.1.0"},
      "evidence": {"evidence": {"status": "VERIFIED"}, "proof_ref": "sha256:9f2c…"},
      "decision": {"admission": "ADMIT"}
    },
    "verdict": "VERIFIED",
    "attestation": "<ES256 JWT from from-diagnostic>"
  }
}
```

**Response:**

```json theme={null}
{"resolved": true}
```

`resolved` is `true` only when the attestation verifies (unless `require_attestation` is `false`), the document has `verdict: "VERIFIED"`, it validates against the schema, and the stored `proof_ref` matches the re-derived commitment. An attestation failure returns `{"resolved": false, "error": "attestation_required"}` or `"attestation_invalid"`. Any other failure (non-`VERIFIED` verdict, schema failure, mismatched or missing commitment) returns `{"resolved": false}`. A `proof_ref` that cannot be resolved confers no authority.

***

## Agent endpoints

### POST /agents/register

Register a new AI agent with QWED for verified agentic workflows.

**Request:**

```json theme={null}
{
  "name": "FinanceBot",
  "agent_type": "semi_autonomous",
  "description": "Financial analysis agent",
  "permissions": ["math", "logic", "code"],
  "max_cost_per_day": 50.0
}
```

| Parameter | Type | Required | Default | Description |
| - | - | - | - | - |
| `name` | string | Yes | — | Agent display name |
| `agent_type` | string | No | `autonomous` | `autonomous`, `semi_autonomous`, or `assistant` |
| `description` | string | No | — | Agent description |
| `permissions` | array | No | `[]` | Allowed verification engine names (e.g., `math`, `logic`, `code`) |
| `max_cost_per_day` | float | No | `100.0` | Daily budget cap in USD |

**Agent types:**

| Type | Description |
| - | - |
| `autonomous` | Fully autonomous agent (AutoGPT-style) |
| `semi_autonomous` | Requires approval for critical actions |
| `assistant` | Human-in-the-loop |

**Response:**

```json theme={null}
{
  "agent_id": 42,
  "agent_token": "qwed_agent_...",
  "name": "FinanceBot",
  "type": "semi_autonomous",
  "status": "active",
  "max_cost_per_day": 50.0,
  "message": "Agent registered successfully. Store the agent_token securely."
}
```

> [!WARNING]
>
> Store the `agent_token` immediately. QWED stores only a keyed digest of the token, so it can't be retrieved again. If you lose it, re-issue it with [`POST /agents/{agent_id}/token/reissue`](#post-%2Fagents%2F%2Ftoken%2Freissue).

***

### GET /agents

List the agents of your organization, oldest first. Use it to find an agent's ID, check its status, or see when it last authenticated, for example before reading its [activity](#get-%2Fagents%2F%2Factivity).

Owners and admins can call this endpoint with an API key or a signed-in session. When you send both, QWED uses the API key.

**Headers (one of):**

```text theme={null}
X-API-Key: <api_key>
Authorization: Bearer <jwt_token>
```

```bash theme={null}
curl --fail-with-body http://localhost:8000/agents \
  -H "X-API-Key: $QWED_API_KEY"
```

**Response:**

```json theme={null}
[
  {
    "id": 42,
    "name": "FinanceBot",
    "type": "semi_autonomous",
    "status": "active",
    "created_at": "2026-10-20T09:15:00Z",
    "last_active": "2026-10-28T12:00:00Z"
  },
  {
    "id": 43,
    "name": "ReportBot",
    "type": "assistant",
    "status": "suspended",
    "created_at": "2026-10-21T14:30:00Z",
    "last_active": null
  }
]
```

| Field | Type | Description |
| - | - | - |
| `id` | integer | Agent ID |
| `name` | string | Agent display name |
| `type` | string | The type given at registration (default `autonomous`) |
| `status` | string | `active`, `suspended`, or `deactivated`. QWED lists agents in every status. |
| `created_at` | string | When the agent was registered, in ISO 8601 with its UTC offset |
| `last_active` | string \| null | When the agent last authenticated with its token, in ISO 8601 with its UTC offset. `null` until the agent first authenticates. |

The response never includes the agent token or its digest. QWED shows the token only once, at registration or [re-issue](#post-%2Fagents%2F%2Ftoken%2Freissue).

| Status | Description |
| - | - |
| 401 | No API key or bearer token, or an invalid or expired bearer token |
| 403 | An invalid, revoked, or expired API key; the caller is a `member` or `viewer`; or the user, organization, or key is inactive |

***

### POST /agents/{agent_id}/token/reissue

Issue a new token for an agent. The previous token stops working immediately. Use this to replace a lost token, or a token whose plain text might remain in a database backup from before QWED began storing agent tokens as digests.

Authenticate with your API key in the `X-API-Key` header. The key's owner must hold the `member`, `admin`, or `owner` role, and the agent must belong to the key's organization.

```bash theme={null}
curl --fail-with-body -X POST http://localhost:8000/agents/42/token/reissue \
  -H "X-API-Key: qwed_live_..."
```

**Response:**

```json theme={null}
{
  "agent_id": 42,
  "agent_token": "qwed_agent_...",
  "message": "Agent token re-issued. Store it securely; the previous token no longer works."
}
```

| Status | Meaning |
| - | - |
| `403` | The API key isn't live, or the key owner's role is below `member` |
| `404` | No agent with this ID exists in your organization, including an agent in another organization |

> [!WARNING]
>
> The new `agent_token` is shown only once. Store it securely.

***

### POST /agents/{agent_id}/verify

> [!NOTE]
>
> Updated in v5.0.0

Verify a claim using an agent token. Creates an auditable record tied to the agent. Security checks are enforced server-side — exfiltration detection always runs, and MCP poisoning detection runs automatically when a `tool_schema` is provided.

> [!WARNING]
>
> **Breaking change (v5.0.0):** The `security_checks` request field has been removed. Security checks are now mandatory and enforced server-side. You no longer need to (or can) opt in to exfiltration or MCP poison checks.

> [!WARNING]
>
> **Breaking change (v5.0.0):** The `context` field with `conversation_id` and `step_number` is now required for all agent action verification requests. Requests without these fields are rejected with error code `QWED-AGENT-CTX-001`. See [conversation controls](https://docs.qwedai.tech/advanced/agent-verification.md#conversation-controls) for details.

**Request:**

```json theme={null}
{
  "query": "What is 15% of 200?",
  "provider": "openai",
  "context": {
    "conversation_id": "conv_abc123",
    "step_number": 1,
    "pre_action_state_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
    "state_source": "db_snapshot"
  },
  "tool_schema": {
    "name": "fetch_report",
    "description": "Fetch quarterly report data"
  }
}
```

| Parameter | Type | Required | Description |
| - | - | - | - |
| `query` | string | Yes | The claim to verify |
| `provider` | string | No | LLM provider preference |
| `context` | object | Yes | Action context — see below |
| `context.conversation_id` | string | Yes | Unique identifier for the conversation/session |
| `context.step_number` | integer | Yes | Monotonically increasing step counter (>= 1) |
| `context.pre_action_state_hash` | string | Conditional | SHA-256 hex digest (64 lowercase hex characters) of the world state before the action. Must be provided together with `state_source`. Enables [LOOP-004](https://docs.qwedai.tech/specs/agent.md#9-4-progress-aware-doom-loop-detection-loop-004) detection |
| `context.state_source` | string | Conditional | Declares how `pre_action_state_hash` was derived. One of: `file_tree`, `db_snapshot`, `conversation_digest`, `git_tree`, `custom`. Must be provided together with `pre_action_state_hash` |
| `tool_schema` | object | No | MCP tool definition to scan. When provided, `MCPPoisonGuard` runs automatically |

**Headers:**

```text theme={null}
X-Agent-Token: qwed_agent_...
```

**Security behavior:**

* **Exfiltration check:** Always runs on every agent verification request. If the query payload is flagged, the request is rejected with a `403`.
* **MCP poison check:** Runs automatically when `tool_schema` is present. If the tool definition is flagged, the request is rejected with a `403`.

**Response:**

The route returns the enforced result with its `admission`, in the shape `POST /verify/natural_language` returns: the `DiagnosticResult` fields, with the engine's `developer_fields` also copied to the top level. QWED signs a `VERIFIED` result and enforces the attestation as mandatory. If the attestation cannot be issued or does not match, the result is `BLOCKED`, with `verdict_status` and `verdict_proof_ref` kept beside the block (see [Attestation enforcement](#attestation-enforcement)). The agent activity log records the same response.

```json theme={null}
{
  "status": "VERIFIED",
  "agent_message": "Expression verified successfully",
  "developer_fields": {
    "status": "VERIFIED",
    "calculated_value": 30.0,
    "is_valid": true,
    "translated_expression": "0.15 * 200",
    "verification_scope": "translated_expression_only",
    "query_semantics_verified": false
  },
  "proof_ref": "sha256:7a3d…",
  "is_authoritative": true,
  "calculated_value": 30.0,
  "is_valid": true,
  "translated_expression": "0.15 * 200",
  "verification_scope": "translated_expression_only",
  "query_semantics_verified": false,
  "admission": "BLOCKED"
}
```

Gate on `admission`, not `status`. A natural-language verdict covers the expression the query was translated into, so `admission` is `BLOCKED` unless the query semantics were verified.

> [!WARNING]
>
> **Breaking change:** This route used to return the control plane's raw response, which could still say `VERIFIED` after enforcement had blocked the result. Callers that read `translation`, `final_answer`, or `trust_boundary` from this route must move to `status`, `proof_ref`, and `admission`.

**Error responses:**

| Status | Description |
| - | - |
| 401 | Invalid agent token |
| 403 | Agent budget exceeded or request blocked by security checks |
| 500 | Internal agent verification error, or `audit persistence failed` |

**Audit trail:** Before QWED logs the agent activity and returns, it appends the same signed, hash-chained audit row that `POST /verify/natural_language` writes, with domain `NATURAL_LANGUAGE`, plus the `agent_id`. If that append fails, the request returns `500` with `audit persistence failed` and no agent activity is logged.

***

### POST /agents/{agent_id}/tools/{tool_name}

Submit an agent tool call for risk evaluation before execution. Unknown tools (not on the safe or dangerous operations list) are denied by default, regardless of risk score. See [tool approval policy](https://docs.qwedai.tech/advanced/agent-verification.md#tool-approval-policy) for details.

**Request:**

```json theme={null}
{
  "tool_params": {
    "query": "SELECT * FROM users",
    "dialect": "postgresql"
  }
}
```

**Possible outcomes:**

| Outcome | When |
| - | - |
| Approved | Tool is on the safe operations list |
| Blocked (manual approval required) | Tool is on the dangerous operations list |
| Blocked (default-deny) | Tool is not on either list |

***

### GET /agents/{agent_id}/activity

Retrieve the activity log for a specific agent, newest first. Entries with the same timestamp are ordered by ID, newest first. Activity entries are not signed or hash-chained. The signed record of each agent verification is its row in the organization's audit log (see [`GET /audit/logs`](https://docs.qwedai.tech/api/authentication#get-%2Faudit%2Flogs)).

An agent can read its own activity with its token. An owner or admin can read the activity of any agent in their organization with an API key or a signed-in session.

**Headers (one of):**

```text theme={null}
X-Agent-Token: qwed_agent_...
X-API-Key: <api_key>
Authorization: Bearer <jwt_token>
```

When you send `X-Agent-Token`, the token alone decides access. It must be the active agent's own token, or QWED returns `401`, even if you also send an owner's API key or session. Without `X-Agent-Token`, QWED checks the API key or session for the `owner` or `admin` role.

```bash theme={null}
curl --fail-with-body "http://localhost:8000/agents/42/activity?limit=50" \
  -H "X-API-Key: $QWED_API_KEY"
```

**Query params:**

| Parameter | Type | Default | Description |
| - | - | - | - |
| `limit` | integer | `20` | Maximum number of activity records to return, from `1` to `200` |

**Response:**

```json theme={null}
{
  "agent_id": 42,
  "agent_name": "FinanceBot",
  "total_activities": 5,
  "current_cost_today": 0.05,
  "max_cost_per_day": 50.0,
  "activities": [
    {
      "type": "verification_request",
      "description": "Query: What is 15% of 200?",
      "status": "success",
      "cost": 0.01,
      "timestamp": "2026-03-20T12:00:00+00:00"
    }
  ]
}
```

Each `timestamp` is ISO 8601 with its UTC offset.

| Status | Description |
| - | - |
| 401 | `X-Agent-Token` is invalid or belongs to another agent; no credential was sent; or, without an agent token, an invalid or expired bearer token |
| 403 | Without an agent token: an invalid, revoked, or expired API key; the caller is a `member` or `viewer`; or the user, organization, or key is inactive |
| 404 | Without an agent token: no agent with this ID exists in your organization, including an agent in another organization or an ID out of range |
| 422 | `limit` is outside `1` to `200` |

***

## Attestation endpoints

### GET /.well-known/jwks.json

The public keys that verify the attestations the server signs (ES256). No authentication required. Check an attestation's signature with the key whose `kid` matches the token's header. The server has no route that looks up or verifies an attestation by ID.

```bash theme={null}
curl -sS -f http://localhost:8000/.well-known/jwks.json
```

***

## Observability endpoints

### GET /metrics

Returns global system metrics and per-tenant breakdowns. This is a cross-tenant, platform-operator capability: the caller must be a user ID listed in `QWED_METRICS_OPERATOR_USER_IDS` (comma-separated, fail-closed when unset). Organization roles (`owner` / `admin`) do **not** grant access — they are scoped to a single organization.

**Headers (one of):**

```text theme={null}
Authorization: Bearer <jwt_token>
X-API-Key: <api_key>
```

An API key resolves through its owning user, so the key must belong to an active user on the allowlist. Expired or revoked keys are not usable credentials; if a request carries both an API key and a JWT, the JWT can still authorize the read.

**Response:**

```json theme={null}
{
  "global": { "total_requests": 1250, "avg_latency_ms": 82.3 },
  "tenants": { "1": { "requests": 500 }, "2": { "requests": 750 } }
}
```

| Status | Description |
| - | - |
| 401 | No usable authentication provided (no credentials, or only an expired API key) |
| 403 | Authenticated, but the user is not in `QWED_METRICS_OPERATOR_USER_IDS` |

### GET /metrics/{organization_id}

Returns metrics scoped to a specific organization. Tenants can only view their own metrics.

| Status | Description |
| - | - |
| 403 | You can only view metrics for your own organization |

### GET /metrics/prometheus

Returns metrics in Prometheus text format for scraping by monitoring infrastructure. Requires the same platform-operator authentication as `GET /metrics`.

**Headers (one of):**

```text theme={null}
Authorization: Bearer <jwt_token>
X-API-Key: <api_key>
```

| Status | Description |
| - | - |
| 401 | No usable authentication provided (no credentials, or only an expired API key) |
| 403 | Authenticated, but the user is not in `QWED_METRICS_OPERATOR_USER_IDS` |

### GET /logs

Returns verification logs for the authenticated tenant, ordered by most recent first.

**Query params:**

| Parameter | Type | Default | Description |
| - | - | - | - |
| `limit` | integer | `10` | Maximum number of logs to return |

**Response:**

```json theme={null}
{
  "organization_id": 1,
  "organization_name": "Acme Corp",
  "total_logs": 3,
  "logs": [
    {
      "id": 42,
      "query": "What is 2+2?",
      "is_verified": true,
      "domain": "MATH",
      "timestamp": "2026-03-20T12:00:00Z"
    }
  ]
}
```

***

## Admin endpoints

> [!NOTE]
>
> New in v4.0.0

Admin endpoints authenticate your API key with the `X-API-Key` header, or a [signed-in session](#session-sign-in-for-admin-endpoints), then authorize the request by the role of the user who owns the key or holds the session. Every admin endpoint acts only on your own organization, which is the organization the API key belongs to.

Before QWED checks the role, the request must meet all of these conditions. Otherwise QWED returns `403 Forbidden`:

* The API key is active, not revoked, and not expired.
* The key's owning user is active and belongs to the key's organization.
* The organization is active.

| Endpoint | Allowed roles | Another organization's ID |
| - | - | - |
| `GET /admin/compliance/export/csv` | `owner`, `admin` | `403 Forbidden` |
| `GET /admin/compliance/verify/{log_id}` | `owner`, `admin` | `404 Not Found` |
| `GET /admin/compliance/verify-trail` | `owner`, `admin` | Not applicable (always your organization) |
| `GET /admin/compliance/report/soc2/{org_id}` | `owner`, `admin` | `403 Forbidden` |
| `GET /admin/security/threats/{org_id}` | `owner`, `admin` | `403 Forbidden` |
| `GET /admin/security/events` | `owner`, `admin` | Not applicable (own organization only) |
| `POST /admin/keys/rotate` | `owner`, `admin`, `member` | `404 Not Found` |

A caller whose role isn't allowed gets `403 Forbidden` with `"Insufficient permissions for this operation"`. A request with neither an API key nor a bearer token returns `401 Unauthorized`. An invalid, revoked, or expired API key returns `403 Forbidden`; for a bearer token, see [Session sign-in for admin endpoints](#session-sign-in-for-admin-endpoints).

The JWT-authenticated `/auth/api-keys` and `/audit` routes use the same role ranking. See [Roles on API-key and audit routes](https://docs.qwedai.tech/api/authentication.md#roles-on-api-key-and-audit-routes).

### Session sign-in for admin endpoints

The four `/admin/compliance/*` endpoints, `GET /admin/security/threats/{org_id}`, `GET /admin/security/events`, and `POST /admin/keys/rotate` also accept a signed-in session. A dashboard user doesn't need to paste an API key into the browser. Send the `access_token` from [`POST /auth/signin`](https://docs.qwedai.tech/api/authentication) as a bearer token:

```text theme={null}
Authorization: Bearer <jwt_token>
```

* The token must be valid and its user must exist. Otherwise QWED returns `401 Unauthorized`.
* The user must be active, belong to an active organization, and hold a role the endpoint allows (see the table above). Otherwise QWED returns `403 Forbidden`.
* If you send both `X-API-Key` and a bearer token, QWED uses the API key. If you send neither, QWED returns `401 Unauthorized`.
* The session's organization is the signed-in user's organization. Organization scoping and role rules work the same as with an API key.

```bash theme={null}
curl --fail-with-body -X POST -H "Authorization: Bearer eyJhbG..." \
  "http://localhost:8000/admin/keys/rotate?key_id=42"
```

[`POST /agents/{agent_id}/token/reissue`](#post-%2Fagents%2F%2Ftoken%2Freissue) still accepts only an API key. A bearer token alone returns `401 Unauthorized`.

> [!NOTE]
>
> Before this change, every admin endpoint returned `401 Unauthorized`, even for valid owner and admin keys. Admin endpoints now authorize by role and are scoped to the caller's organization.

### GET /admin/compliance/export/csv

Export your organization's audit trail for a period as a CSV file (`text/csv`). Use it to hand verification evidence to an auditor. [`GET /audit/export`](https://docs.qwedai.tech/api/authentication#get-%2Faudit%2Fexport) writes the same columns.

| Parameter | Type | Default | Description |
| - | - | - | - |
| `organization_id` | integer (query) | Required | Must be your own organization ID |
| `start` | datetime (query) | 30 days before `end` | Export rows at or after this time |
| `end` | datetime (query) | Now | Export rows at or before this time |

Pass `start` and `end` as ISO 8601 times. URL-encode a `+` in an offset as `%2B`; an unencoded `+` reads as a space and the time is refused. A time without a zone is read as UTC, and other offsets are converted. QWED applies the defaults first and then checks the period, so `start` after `end` returns `400 Bad Request` with `"start must not be after end"`. A `start` in the future with no `end` is also `400`, not an empty file.

Passing another organization's ID returns `403 Forbidden` with `"Cannot access another organization's data"`.

```bash theme={null}
curl --fail-with-body -D headers.txt -o audit.csv \
  -H "X-API-Key: qwed_live_your_admin_key" \
  "http://localhost:8000/admin/compliance/export/csv?organization_id=12&start=2026-10-01T00:00:00Z&end=2026-10-28T00:00:00Z"
```

#### CSV columns

QWED writes rows newest first, ordered by timestamp and then by ID. Both CSV exports write these columns:

| Column | Description |
| - | - |
| `ID` | The audit log ID |
| `Timestamp` | When the row was written, in ISO 8601 UTC |
| `User ID` | The user who wrote the row |
| `API Key ID` | The API key that authenticated the request |
| `Verifier Version` | The QWED server version that wrote the row |
| `Domain` | The row's domain, the same value as `engine_used` on [`GET /audit/logs`](https://docs.qwedai.tech/api/authentication#get-%2Faudit%2Flogs), for example `MATH`, `NATURAL_LANGUAGE`, `LOGIC`, or `CODE` |
| `Query` | The stored query, in full |
| `Verdict` | The stored verdict (`VERIFIED`, `UNVERIFIABLE`, or `BLOCKED`), as `verdict` on `/audit/logs` |
| `Admission` | The stored admission decision, as `admission` on `/audit/logs` |
| `Is Verified (legacy)` | `Yes` or `No`, from the row's `is_verified` flag. Each route sets this flag by its own rule. It is neither the verdict nor the admission |
| `Result` | The stored result, in full |
| `Latency (ms)` | The stored latency, as `latency_ms` on `/audit/logs`: whole milliseconds from the start of the HTTP request to when QWED began writing the audit row, before it waits for the append lock. Empty for rows written outside a request or before QWED recorded latency |
| `Entry Hash` | The row's hash in the signed audit chain |
| `HMAC Signature` | The row's full HMAC-SHA256 signature |
| `Chain Status` | `Chained` when the row carries a hash, `Legacy` when it was written before the API used the hash chain |

A value that QWED didn't record is an empty cell. QWED doesn't truncate any cell. `Chained` doesn't mean the row was verified. Use [`GET /admin/compliance/verify/{log_id}`](#get-%2Fadmin%2Fcompliance%2Fverify%2F) or [`GET /admin/compliance/verify-trail`](#get-%2Fadmin%2Fcompliance%2Fverify-trail) to verify rows.

The query and result are caller-controlled. To stop spreadsheet software from running a cell as a formula, QWED prefixes an apostrophe (`'`) to any cell that starts with `=`, `+`, `-`, `@`, a tab, a carriage return, or a line feed. Strip that apostrophe if you compare a cell with the stored value.

#### Row cap

Each export holds at most the newest 1,000 rows in the period. When more rows matched, the response sets `X-Export-Truncated: true`, otherwise `false`. CORS exposes this header, so a browser client on another allowed origin can read it. To export older rows, request again with `end` set to the oldest `Timestamp` in the file, and skip rows whose `ID` you already have.

### GET /admin/compliance/verify/{log_id}

Cryptographically verify a specific audit log entry using HMAC-SHA256 and hash-chain validation.

The entry must belong to your organization. QWED returns `404 Not Found` for a log ID in another organization, so the response doesn't reveal whether that entry exists.

The response reports three independent checks: payload hash, HMAC signature (compared in constant time), and chain linkage to the prior entry within the same organization. Genesis entries must have a `null` `previous_hash`; non-genesis entries must link to a non-empty prior entry hash. Entries with malformed stored result payloads cause verification to fail closed with `SecurityError` instead of returning `"valid": true`.

Hash matching accepts either the current canonical payload (which includes `raw_llm_output`) or the legacy canonical payload, so entries written before that field was covered remain verifiable after the upgrade.

**Response:**

```json theme={null}
{
  "valid": true,
  "legacy": false,
  "checks": {
    "hash_valid": true,
    "signature_valid": true,
    "chain_valid": true
  },
  "errors": [],
  "log_id": 42,
  "timestamp": "2026-03-20T12:00:00Z"
}
```

**Legacy entries.** Rows written before the API used the hash chain have no hash or signature. For an unchained row with no chained row before it, the response sets `"legacy": true` and `"valid": false`, with all three checks `false`:

```json theme={null}
{
  "valid": false,
  "legacy": true,
  "checks": {
    "hash_valid": false,
    "signature_valid": false,
    "chain_valid": false
  },
  "errors": ["Entry predates the audit chain (no hash or signature); it cannot be verified"],
  "log_id": 7,
  "timestamp": "2026-10-01T09:00:00+00:00"
}
```

An unchained row after a chained row is not legacy. It fails verification. See [Legacy entries](https://docs.qwedai.tech/advanced/compliance.md#legacy-entries).

> [!NOTE]
>
> `AuditLogger` requires `QWED_AUDIT_SECRET_KEY` to be set, and the server refuses to start without it. If the key is missing or persisted chain continuity cannot be loaded, initialization raises `SecurityError` and verification endpoints fail closed. Every `/verify/*` endpoint (one row per item for `/verify/batch`), the Verification Context routes, `POST /agents/{agent_id}/verify`, and `POST /audit/seed` write their audit row through the signed chain. If that write fails, the request returns `500` with `audit persistence failed`.

### POST /audit/seed

Write 10 sample audit rows for your organization through the signed hash chain. Use this only in development. Each row is marked `{"sample": true}` in its result and timestamped when written.

The route is disabled by default. Set `QWED_ENABLE_AUDIT_SEED=1` on the server to enable it.

| Status | Description |
| - | - |
| 404 | Sample seeding is disabled (`QWED_ENABLE_AUDIT_SEED` is not `1`). No rows are written |
| 500 | Audit persistence failed |

### GET /admin/compliance/report/soc2/{org_id}

Return a security activity summary for your organization over the last 90 days. `org_id` must be your own organization ID. Another organization's ID returns `403 Forbidden`.

QWED computes every value from its own records. The response has `report_type` `"Security activity summary"`, `report_version` `"2.0"`, and a `disclaimer` stating that it is not a SOC 2 report or an auditor's attestation. The path keeps `soc2` for compatibility.

* `metrics` counts verifications, security events (`security_events_by_type` groups them by recorded event type), active users, and active API keys.
* `audit_trail` checks the signature and hash chain of every audit entry in the period. `status` is `VERIFIED` (with `evidence` and a `sha256:` `proof_ref`), `NO_ENTRIES`, or `NOT_VERIFIED`. The check fails closed: any failed or legacy entry, or a check that can't run (for example, `QWED_AUDIT_SECRET_KEY` unset), returns `NOT_VERIFIED`.

```bash theme={null}
curl --fail-with-body -H "X-API-Key: qwed_live_your_key" \
  "http://localhost:8000/admin/compliance/report/soc2/1"
```

`audit_trail.first_failed_entry_id` is the lowest entry ID that failed its signature or chain check, or `null` when none failed or the check couldn't run.

See [Security activity summary](https://docs.qwedai.tech/advanced/compliance.md#1-security-activity-summary) for the full response and field reference.

### GET /admin/compliance/verify-trail

Check the signature and hash chain of every audit entry in your organization over a period. Use it to confirm the chain is intact up to a given entry, instead of calling `GET /admin/compliance/verify/{log_id}` once per entry. Owners and admins can call it with an API key or a signed-in session.

| Parameter | Type | Description |
| - | - | - |
| `start` | ISO 8601 datetime (query, optional) | Start of the period. Omit for no lower bound. |
| `end` | ISO 8601 datetime (query, optional) | End of the period. Omit for no upper bound. |

With neither parameter, QWED checks the whole chain, and `scope` reads `"Signature and hash-chain check of every audit entry recorded for the organisation. It does not show that every request was recorded."` With `start`, `end`, or both, `scope` names the period, as in the example below. Only the text differs. The status, counts, and `proof_ref` are computed the same way. QWED reads a time without a zone as UTC and converts any other offset to UTC. A `start` later than `end` returns `400 Bad Request`.

```bash theme={null}
curl --fail-with-body -H "Authorization: Bearer $QWED_SESSION_TOKEN" \
  "http://localhost:8000/admin/compliance/verify-trail?start=2026-10-01T00:00:00Z"
```

**Response:**

```json theme={null}
{
  "organization_id": 1,
  "start": "2026-10-01T00:00:00+00:00",
  "end": null,
  "status": "NOT_VERIFIED",
  "proof_ref": null,
  "evidence": null,
  "scope": "Signature and hash-chain check of the audit entries recorded in the period. It does not show that every request was recorded.",
  "total_entries": 240,
  "verified_entries": 238,
  "failed_entries": 2,
  "legacy_entries": 0,
  "first_failed_entry_id": 1187,
  "errors": ["..."],
  "total_errors": 2,
  "errors_truncated": false
}
```

The fields after `end` match `audit_trail` in the [security activity summary](https://docs.qwedai.tech/advanced/compliance.md#audit-trail-status). `status` is `VERIFIED` (with `evidence` and a `sha256:` `proof_ref`) only when the period has entries and all pass. `NO_ENTRIES` means the period has none. `NOT_VERIFIED` means any entry failed, any entry is a legacy entry, or the check couldn't run. `first_failed_entry_id` names the lowest entry ID that failed, or is `null`.

### GET /admin/security/threats/{org_id}

Returns a real-time threat summary for your organization, including blocked injection attempts and anomalous patterns. `org_id` must be your own organization ID. Another organization's ID returns `403 Forbidden`.

Owners and admins can call this endpoint with an API key in the `X-API-Key` header or with a [signed-in session](#session-sign-in-for-admin-endpoints). The summary's `generated_at` is UTC with a `+00:00` offset.

```bash theme={null}
curl --fail-with-body -H "Authorization: Bearer eyJhbG..." \
  "http://localhost:8000/admin/security/threats/1"
```

### GET /admin/security/events

List your organization's security events, such as a leaked API key that QWED revoked automatically. Use this to review individual events. `GET /admin/security/threats/{org_id}` returns only a summary.

Owners and admins can call this endpoint with an API key in the `X-API-Key` header or with a signed-in session (`Authorization: Bearer <jwt>`). When you send both, QWED uses the API key. QWED returns only events from your own organization.

| Parameter | Type | Default | Description |
| - | - | - | - |
| `limit` | integer | `50` | Maximum events, from 1 to 200 |
| `before` | integer | — | Return only events older than this event ID. Pass the `next_before` value from the previous page |
| `event_type` | string | — | Only events of this type, for example `SECRET_LEAK_AUTO_REVOKED` |

```bash theme={null}
curl --fail-with-body -G -H "X-API-Key: qwed_live_your_key" \
  --data-urlencode "limit=50" \
  --data-urlencode "event_type=SECRET_LEAK_AUTO_REVOKED" \
  "http://localhost:8000/admin/security/events"
```

**Response:**

```json theme={null}
{
  "total": 1,
  "next_before": null,
  "events": [
    {
      "id": "318",
      "timestamp": "2026-10-22T09:14:03+00:00",
      "event_type": "SECRET_LEAK_AUTO_REVOKED",
      "severity": "high",
      "security_layer": null,
      "reason": "Public leak auto-revoked: source=github url=https://github.com/example/repo/blob/main/.env",
      "query": "leaked key qwed_live_ab...",
      "user_id": 12
    }
  ]
}
```

Events come newest first, by timestamp and then ID, as on [`GET /audit/logs`](https://docs.qwedai.tech/api/authentication#paging-through-the-log). `next_before` is the last event's ID when the page is full, otherwise `null`. `total` is the number of events on this page. QWED replaces anything shaped like an API key (`qwed_live_…`, `qwed_test_…`) or agent token in `reason` and `query` with `[redacted credential]`.

| Status | Meaning |
| - | - |
| 401 | No API key or bearer token |
| 403 | Not an owner or admin, or an inactive user, organization, or key |
| 404 | The `before` cursor is not an event in your organization, or is outside the SQL `BIGINT` range |

### POST /admin/keys/rotate

Rotate an API key and return the new secret once. Owners, admins, and members can call this endpoint with an API key in the `X-API-Key` header or with a [signed-in session](#session-sign-in-for-admin-endpoints). The same rules apply either way:

* **Your own key:** any allowed role can rotate a key it owns.
* **Another user's key:** only an `owner` or `admin` can rotate it, and only when the key's owner doesn't hold a higher role. An admin can't rotate an owner's key. A member can't rotate anyone else's key. These cases return `403 Forbidden`.
* **Organization scope:** the key must belong to your organization. A key ID in another organization returns `404 Not Found`, without revealing that the key exists.
* **Live keys only:** QWED treats a revoked or inactive key as absent and returns `404 Not Found`. You can't rotate a dead credential into a new working secret.
* **Atomic:** QWED revokes the old key and mints the replacement in one transaction. Concurrent rotations of the same key produce exactly one replacement. The others return `404 Not Found`.

| Parameter | Type | Description |
| - | - | - |
| `key_id` | integer (query) | ID of the key to rotate |

```bash theme={null}
curl --fail-with-body -X POST -H "X-API-Key: qwed_live_your_key" \
  "http://localhost:8000/admin/keys/rotate?key_id=42"
```

**Response:**

```json theme={null}
{
  "status": "rotated",
  "old_key_id": 42,
  "new_key_id": 57,
  "new_key_preview": "qwed_live_ab...",
  "new_key_secret": "qwed_live_..."
}
```

Store `new_key_secret` immediately. QWED returns it only in this response.

***

## Webhook endpoints

### POST /webhooks/secret-scanning

Receive signed secret-leak notifications from GitHub secret scanning (or any GitHub-compatible partner scanner). The endpoint is the intake side of the leaked-QWED-key revocation pipeline: authenticated reports of leaked tokens are handed to the revocation sink after signature verification succeeds.

The endpoint is **intentionally unauthenticated** — it must be reachable by external scanners that have no QWED credentials. The ECDSA signature over the raw request body is the only credential; nothing in the payload is parsed, logged, or persisted until the signature is verified against GitHub's published public keys.

**Request headers:**

| Header | Required | Description |
| - | - | - |
| `Github-Public-Key-Identifier` | Yes | Key identifier (`key_identifier`) of the signing key from `https://api.github.com/meta/public_keys/secret_scanning` |
| `Github-Public-Key-Signature` | Yes | Base64-encoded ASN.1/DER ECDSA-NIST-P256V1-SHA256 signature over the raw request body |

**Request body:**

A non-empty JSON array of match objects. Extra fields on each item are ignored so scanners can add informational fields over time.

```json theme={null}
[
  {
    "token": "qwed_live_KEY",
    "type": "qwed_live_api_key",
    "url": "https://github.com/acme-corp/billing-api/blob/main/config.py",
    "source": "commit"
  }
]
```

| Field | Type | Required | Description |
| - | - | - | - |
| `token` | string | Yes | The leaked token (1–512 characters). Never logged. |
| `type` | string | Yes | Scanner-declared token type (1–128 characters) |
| `url` | string | No | Location where the token was found (max 4096 characters) |
| `source` | string | No | Scanner-declared source (max 64 characters). Defaults to `"unknown"` |

**Envelope caps:**

* Request body: 1 MiB. The stream is bounded incrementally — an over-cap body is rejected the moment the cap is exceeded and never fully buffered.
* Matches per request: 500.
* Signature header: 1024 characters before base64 decode.

**Response (200):**

```json theme={null}
{"received": 3}
```

A 200 acknowledges a **verified receipt**, not that downstream revocation has finished. Verified matches are handed to a background sink after the response is sent.

**Background handling of verified matches:**

* **Active key:** revoked immediately (`is_active=False`, `revoked_at` set). QWED writes a `SECRET_LEAK_AUTO_REVOKED` security event that carries only the `key_preview`, never the token.
* **Owner email:** sent to the key's user, or to the organization's first active owner or admin as a fallback. SMTP has a 10-second timeout. A failed notification never rolls back the revocation.
* **Already-revoked key or unknown token:** no revocation or owner notification. Redeliveries are idempotent and never send a second email. When false-positive feedback is enabled (next bullet), QWED still reports a label for the match — for an unknown token, `false_positive`.
* **Test keys:** registered scanner patterns cover `qwed_live_` keys only, so `qwed_test_` keys are not reported. The revocation sink itself does not filter by prefix.
* **False-positive feedback (opt-in):** when `QWED_LEAK_FEEDBACK_ENABLED=true`, QWED sends a `true_positive` or `false_positive` label per match to `QWED_LEAK_FEEDBACK_URL` after the revocation attempt. Items carry SHA-256 token hashes unless `QWED_LEAK_FEEDBACK_SEND_RAW=true` is set with an HTTPS URL.

See [Automatic revocation of leaked keys](https://docs.qwedai.tech/advanced/security-hardening.md#automatic-revocation-of-leaked-keys) and [False-positive feedback reporter](https://docs.qwedai.tech/advanced/security-hardening.md#false-positive-feedback-reporter) for details.

**Status codes:**

| Status | Meaning |
| - | - |
| 200 | Signature verified; matches queued for background handling |
| 400 | Malformed envelope (non-JSON body, wrong shape, or a match object failed validation) |
| 401 | Missing `Github-Public-Key-Identifier` or `Github-Public-Key-Signature` header |
| 403 | Signature invalid, forged, tampered, or signed by an unknown key against a fresh trust anchor |
| 413 | Body exceeds 1 MiB or the batch exceeds 500 matches |
| 503 | Signing-key service unavailable, or the key that signed the request cannot be resolved after a rotation retry — the scanner should redeliver |

All signature-failure responses share a single generic detail (`"invalid webhook signature"`) so the failing verification step is not observable to the caller.

> [!NOTE]
>
> Configure your scanner (or a GitHub partner-program registration) to POST to `https://<your-qwed-host>/webhooks/secret-scanning`. No API key or bearer token is provisioned on QWED's side — the signature over the raw body is the sole credential, and QWED fetches the public keys from GitHub on demand. See [Security hardening — Secret-scanning webhook intake](https://docs.qwedai.tech/advanced/security-hardening.md#6-secret-scanning-webhook-intake) for the trust model and operator configuration.


## Related topics

- [QWED CLI reference](https://docs.qwedai.tech/advanced/cli.md)
- [Prompt injection defense and QWED security hardening](https://docs.qwedai.tech/advanced/security-hardening.md)
- [QWED compliance guide](https://docs.qwedai.tech/advanced/compliance.md)
- [Changelog Archive](https://docs.qwedai.tech/changelog-archive.md)
- [Python SDK](https://docs.qwedai.tech/sdks/python.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.