> ## 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

```text theme={null}
https://api.qwedai.com/v1
```

## Health check

### GET /health

Check API status. No authentication required.

```bash theme={null}
curl https://api.qwedai.com/v1/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`](/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"
  }
}
```

| 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>` |

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` or `QWED_API_KEY_LOOKUP_SECRET` 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](/advanced/self-hosting#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 response follows the `VerificationResult` schema. For math queries, the top-level `status` is `INCONCLUSIVE` because the LLM translation step is not formally verified — see [Trust boundary](/engines/math#trust-boundary) for details.

```json theme={null}
{
  "status": "INCONCLUSIVE",
  "user_query": "What is 15% of 200?",
  "translation": {
    "expression": "0.15 * 200",
    "claimed_answer": 30.0,
    "reasoning": "15% as decimal is 0.15, multiply by 200",
    "confidence": 0.95
  },
  "verification": {
    "calculated_value": 30.0,
    "is_correct": true,
    "diff": 0.0
  },
  "trust_boundary": {
    "query_interpretation_source": "llm_translation",
    "query_semantics_verified": false,
    "verification_scope": "translated_expression_only",
    "deterministic_expression_evaluation": true,
    "formal_proof": false,
    "translation_claim_self_consistent": true,
    "provider_used": "openai_compat",
    "overall_status": "INCONCLUSIVE"
  },
  "final_answer": 30.0,
  "provider_used": "openai_compat",
  "latency_ms": 245.3
}
```

**Status values for natural language math verification:**

| Status | Meaning |
| - | - |
| `INCONCLUSIVE` | Expression evaluation succeeded, but the LLM translation step is non-deterministic, so you cannot treat the result as a proven verdict on the original user query |
| `ERROR` | The translated expression had a syntax error or the engine could not evaluate it |
| `NOT_MATH_QUERY` | The query was not recognized as a mathematical question |
| `BLOCKED` | Request blocked by security policy |

<Note>
  Previous versions set the `status` field to the inner engine result (e.g., `VERIFIED`). It now returns `INCONCLUSIVE` when the inner engine returns `VERIFIED` or `CORRECTION_NEEDED`, because the natural language pipeline involves a non-deterministic LLM translation step. Use the `trust_boundary` object to inspect the detailed verification breakdown. For fully deterministic results, use `POST /verify/math` directly.
</Note>

**`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 `CORRECTION_NEEDED` |
| `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 top-level `status` field. |

#### 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`.
* 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](/advanced/attestations) for the full `AttestationResult` contract and [Trust boundary](/engines/math#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.
</Note>

***

### POST /verify/math

<Info>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.</Info>

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}
{
  "is_valid": true,
  "result": true,
  "left_side": "x**2 + 2*x + 1",
  "right_side": "(x + 1)**2",
  "simplified_difference": "0",
  "message": "Identity is true"
}
```

**Response (expression):**

```json theme={null}
{
  "is_valid": true,
  "value": 4.0,
  "simplified": "4",
  "original": "2 + 2"
}
```

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`, and `status: "BLOCKED"`. The response includes `warning: "ambiguous"` and a `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",
  "message": "Expression may be ambiguous due to implicit multiplication after division",
  "simplified": "..."
}
```

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

**Tolerance bounding:** When verifying 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`.

**Response (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": "(AND (GT x 5) (LT y 10))",
  "provider": "openai"
}
```

| Parameter | Type | Required | Description |
| - | - | - | - |
| `query` | string | Yes | Logical constraint in DSL or natural language |
| `provider` | string | No | Preferred LLM provider |

**Response:**

```json theme={null}
{
  "status": "SAT",
  "model": {"x": "6", "y": "9"},
  "provider_used": "openai_compat"
}
```

The `provider_used` field indicates which LLM provider handled the translation step. This field is included in both success and error responses, which helps with debugging provider routing issues.

| Status | Meaning |
| - | - |
| `SAT` | Satisfiable — a solution exists |
| `UNSAT` | Unsatisfiable — no solution possible |
| `BLOCKED` | Request blocked by security policy (returns HTTP 403) |
| `ERROR` | Internal verification error |

**Error response:**

```json theme={null}
{
  "status": "ERROR",
  "error": "Internal verification 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 the `ERROR` response above.

***

### POST /verify/code

<Info>Updated in v7.0.0 (breaking)</Info>

Check code for security vulnerabilities using AST analysis. Returns the unified [`DiagnosticResult`](/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`.
</Warning>

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

***

### POST /verify/sql

<Info>Updated in v7.0.0 (breaking)</Info>

Validate SQL queries against a provided schema. Returns the unified [`DiagnosticResult`](/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`, and `sql_verifier.execution_error`. See the [SQL engine](/engines/sql) 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

<Info>New in v4.0.0 · Updated in v5.0.0</Info>

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.</Note>

**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](/engines/consensus#sub-engine-status-contract) page for the full sub-engine contract.
</Note>

| 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 https://api.qwedai.com/v1/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 https://api.qwedai.com/v1/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`](/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.
</Warning>

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](/engines/stats) 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`.
</Note>

***

### POST /verify/process

<Info>New in v4.0.1</Info>

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

<Info>New in v4.0.1</Info>

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`.</Note>

**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. For math items the verdict is the [`DiagnosticResult`](/advanced/diagnostics) record nested under the item's `result` — the same three-layer verdict shape used by `POST /verify/math` — so batch and single-item math verifications share one contract:

* `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`.

```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 verified",
        "developer_fields": {
          "query": "x**2 + 2*x + 1 = (x+1)**2",
          "type": "math",
          "is_valid": true,
          "diff": "0"
        },
        "proof_ref": "sha256:9f2c…",
        "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,
        "is_authoritative": false
      },
      "latency_ms": 8.1
    }
  ]
}
```

#### Math items: proof vs. simplification

Math items are routed through the trust boundary (`enforce_trust_decision(require_attestation=True)` on VERIFIED, with a signed attestation issued via `create_verification_attestation`). Three outcomes are possible:

| Result | `status` | `proof_ref` | When |
| - | - | - | - |
| Verified identity | `VERIFIED` | `sha256:...` | Equality claim (e.g., `x**2 + 2*x + 1 = (x+1)**2`) simplifies to `0` **and** attestation signing succeeds. |
| Not equal | `UNVERIFIABLE` | `null` | Equality claim where the two sides do not simplify to the same expression. `developer_fields.is_valid` is `false` and `developer_fields.diff` shows the non-zero difference. |
| Simplification only | `UNVERIFIABLE` | `null` | Expression without an equality (e.g., `x + x`). `developer_fields.simplified` contains the simplified form. |

If attestation signing fails on an otherwise-verified identity, the item is downgraded to `UNVERIFIABLE` with `developer_fields.constraint_id = "api.attestation.signing_error"` — a math result is never returned as `VERIFIED` without a signed proof.

<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`).
</Warning>

<Info>
  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.status == "VERIFIED"` and the presence of `result.proof_ref`. 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.
</Info>

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.

| 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](/advanced/self-hosting#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

<Info>New in v7.1.0</Info>

These endpoints work with [Verification Context v1.0](/specs/verification-context) documents — the standardized JSON record of a verification. All three require an API key and are rate-limited per key.

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

Convert a [`DiagnosticResult`](/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:** the full Verification Context document (`spec_version`, `object`, `context`, `verdict`).

**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/*`.

| Status | Description |
| - | - |
| 422 | The resulting document failed Verification Context validation |
| 500 | Audit persistence failed |

### POST /verification-context/validate

Validate a Verification Context document against the v1.0 JSON Schema, including the verdict/`proof_ref` invariants.

**Request:**

```json theme={null}
{
  "document": {
    "spec_version": "1.0",
    "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"
  }
}
```

**Response:**

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

An invalid document returns `{"valid": false, "error": "validation_failed"}` with HTTP 200. Gate on the `valid` field.

### 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.

**Request:**

```json theme={null}
{
  "document": {
    "spec_version": "1.0",
    "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"
  }
}
```

**Response:**

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

`resolved` is `true` only when the document has `verdict: "VERIFIED"`, validates against the schema, and the stored `proof_ref` matches the re-derived commitment. Any other outcome (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 — it cannot be retrieved again after registration.</Warning>

***

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

<Info>Updated in v5.0.0</Info>

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>

<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](/advanced/agent-verification#conversation-controls) for details.</Warning>

**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](/specs/agent#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`.

**Error responses:**

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

***

### 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](/advanced/agent-verification#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 audit log for a specific agent. Provides a full audit trail of all agent actions.

**Headers:**

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

**Query params:**

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

**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:00Z"
    }
  ]
}
```

| Status | Description |
| - | - |
| 401 | Invalid agent token |

***

## Attestation endpoints

### GET /attestation/{id}

Get an attestation by ID.

### POST /attestation/verify

Verify an attestation JWT.

**Request:**

```json theme={null}
{
  "jwt": "eyJhbGciOiJFUzI1NiIs..."
}
```

***

## 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

<Info>New in v4.0.0</Info>

Admin endpoints authenticate your API key with the `X-API-Key` header, then authorize the request by the role of the user who owns the key. 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/report/soc2/{org_id}` | `owner`, `admin` | `403 Forbidden` |
| `GET /admin/security/threats/{org_id}` | `owner`, `admin` | `403 Forbidden` |
| `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 missing or invalid API key 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.
</Note>

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

Export your organization's audit trail as a CSV file (`text/csv`).

| Parameter | Type | Description |
| - | - | - |
| `organization_id` | integer (query) | Must be your own organization ID |

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

```bash theme={null}
curl --fail-with-body -H "X-API-Key: qwed_live_your_admin_key" \
  "https://api.qwedai.com/v1/admin/compliance/export/csv?organization_id=12"
```

### 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,
  "checks": {
    "hash_valid": true,
    "signature_valid": true,
    "chain_valid": true
  },
  "errors": [],
  "log_id": 42,
  "timestamp": "2026-03-20T12:00:00Z"
}
```

<Note>
  `AuditLogger` requires `QWED_AUDIT_SECRET_KEY` to be set. If the key is missing or persisted chain continuity cannot be loaded, initialization raises `SecurityError` and verification endpoints fail closed.
</Note>

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

Generate a SOC 2 Type II compliance report for your organization. `org_id` must be your own organization ID. Another organization's ID returns `403 Forbidden`.

### 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`.

### POST /admin/keys/rotate

Rotate an API key and return the new secret once. Owners, admins, and members can call this endpoint, subject to these rules:

* **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.

| 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" \
  "https://api.qwedai.com/v1/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.

***

## Badge endpoints

All badge endpoints return SVG images (`image/svg+xml`). You can embed them directly in Markdown or HTML.

### GET /badge/verified

Get a verified or failed badge SVG.

| Parameter | Type | Default | Description |
| - | - | - | - |
| `verified` | boolean | `true` | Whether to show a verified or failed badge |

### GET /badge/status/{status}

Get a badge for any verification status (e.g., `VERIFIED`, `FAILED`, `CORRECTED`, `BLOCKED`, `PENDING`, `ERROR`).

### GET /badge/attestation/{attestation_id}

Get a badge for a specific attestation by ID.

### GET /badge/engine/{engine}

Get a badge for a specific verification engine.

| Parameter | Type | Default | Description |
| - | - | - | - |
| `verified` | boolean | `true` | Whether to show a verified or failed badge |

### GET /badge/custom

Generate a custom badge with configurable label, message, color, and logo.

| Parameter | Type | Default | Description |
| - | - | - | - |
| `label` | string | `QWED` | Left side label |
| `message` | string | `verified` | Right side message |
| `color` | string | — | Hex color (e.g., `#00C853`) |
| `logo` | boolean | `true` | Include QWED logo |

***

## 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](/advanced/security-hardening#automatic-revocation-of-leaked-keys) and [False-positive feedback reporter](/advanced/security-hardening#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](/advanced/security-hardening#6-secret-scanning-webhook-intake) for the trust model and operator configuration.
</Note>


## Related topics

- [QWED compliance guide](/advanced/compliance.md)
- [Prompt injection defense and QWED security hardening](/advanced/security-hardening.md)
- [Changelog Archive](/changelog-archive.md)
- [Python SDK](/sdks/python.md)
- [Determinism levels](/advanced/determinism-guarantee.md)


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