Skip to main content

Base URL

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

Health check

GET /health

Check API status. No authentication required.
Response:

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. No authentication required. Query parameters:
Response:
To verify, compute the same HMACs with your local secrets and compare them in constant time. The secrets themselves are never returned. Error responses: 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. 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. 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:
Response: The endpoint returns the DiagnosticResult 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.
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 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:

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 for the full AttestationResult contract and Trust boundary for the math-engine discussion.
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

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:
Response (equation):
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. 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. Response (proven false):
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. 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.
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):
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.
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):

POST /verify/logic

Verify logical constraints. Routes through the QWED Control Plane. Request:
Response: The response is a DiagnosticResult, with the control plane’s own output under developer_fields and also copied to the top level. A VERIFIED result carries a signed attestation.
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:
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

Updated in v7.0.0 (breaking)
Check code for security vulnerabilities using AST analysis. Returns the unified DiagnosticResult shape plus a separate admission decision. Request:
Response (unsafe code — proven unsafe, not admitted):
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.

POST /verify/sql

Updated in v7.0.0 (breaking)
Validate SQL queries against a provided schema. Returns the unified DiagnosticResult shape plus a separate admission decision. Request:
Response (safe query):
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 page for the full contract.

POST /verify/fact

Verify a factual claim against a provided context. Request:
Response:

POST /verify/consensus

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.
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:
Verification modes: Response:
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 page for the full sub-engine contract.
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:
Response:

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:
Response (execution succeeded):
Breaking change (v7.0.0): the endpoint returns the unified DiagnosticResult 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.
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 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

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):
Request (milestones mode):
Response (IRAC mode):
Response (milestones mode):

POST /verify/rag

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:
max_drm_rate accepts only string values for symbolic precision. Use fraction notation like "1/10" instead of 0.1.
Response:

POST /verify/batch

Verify multiple items in a single request. Processes all items concurrently and returns aggregated results. Maximum 100 items per batch. Request:
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). Math, logic, and natural-language items return the DiagnosticResult 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.
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.
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:
The full response for two math items:

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. 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:
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.
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).
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.

GET /verify/batch/

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

Verification Context endpoints

New in v7.1.0
These endpoints work with Verification Context 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 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 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:
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 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.
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.

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:
Response:
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.
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.

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:
Response:
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:
Agent types: Response:
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.

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. 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):
Response:
The response never includes the agent token or its digest. QWED shows the token only once, at registration or re-issue.

POST /agents//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.
Response:
The new agent_token is shown only once. Store it securely.

POST /agents//verify

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.
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.
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 for details.
Request:
Headers:
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). The agent activity log records the same response.
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.
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: 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//tools/

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 for details. Request:
Possible outcomes:

GET /agents//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). 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):
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.
Query params: Response:
Each timestamp is ISO 8601 with its UTC offset.

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.

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):
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:

GET /metrics/

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

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):

GET /logs

Returns verification logs for the authenticated tenant, ordered by most recent first. Query params: Response:

Admin endpoints

New in v4.0.0
Admin endpoints authenticate your API key with the X-API-Key header, or a signed-in session, 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.
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. The JWT-authenticated /auth/api-keys and /audit routes use the same role ranking. See 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 as a bearer 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.
POST /agents/{agent_id}/token/reissue still accepts only an API key. A bearer token alone returns 401 Unauthorized.
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 writes the same columns. 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".

CSV columns

QWED writes rows newest first, ordered by timestamp and then by ID. Both CSV exports write these columns: 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} or GET /admin/compliance/verify-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/

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:
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:
An unchained row after a chained row is not legacy. It fails verification. See Legacy entries.
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.

GET /admin/compliance/report/soc2/

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.
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 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. 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.
Response:
The fields after end match audit_trail in the security activity summary. 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/

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. The summary’s generated_at is UTC with a +00:00 offset.

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.
Response:
Events come newest first, by timestamp and then ID, as on GET /audit/logs. 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].

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. 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.
Response:
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: 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.
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):
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 and False-positive feedback reporter for details. Status codes: All signature-failure responses share a single generic detail ("invalid webhook signature") so the failing verification step is not observable to the caller.
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 for the trust model and operator configuration.